Skip to content

Protecting Routes with Guards in Angular

The last feature we are going to add is to prevent unauthenticated users from getting to the home page, and to auto redirect logged in users to the home page. Firebase will remember users automatically, so if we are already logged in we should go directly to the home page.

Redirecting the User

Before we create our guard which will help keep our user where they are supposed to be, we are going to implement some redirects that react to the user’s auth state changing.

The problem in our application right now is that when we try to log in or create an account… nothing happens.

These operations are actually happening successfully, it’s just that our application doesn’t care. What we need to do is have our components react to the authState from Firebase changing by triggering a navigation. We already have a convenient way to do this. Whenever our authState observable emits we set our user state in our AuthService. This results in the following possibilities:

  • If we do not yet know if the user is authenticated, the user() signal will be undefined
  • If the user is unauthenticated the user() signal will be null
  • If the user is authenticated the user() signal will be the User from Firebase

This user() signal updates automatically whenever we change the user’s authState in any way — whether that happens because of a login, logout, create account, or anything else.

We also have a convenient way to trigger running some code, like a navigation, by using the effect Signal API. That means we can just add some effects to each of our components to handle the navigation:

  • The LoginComponent should react by navigating to the home route when the user() signal becomes truthy (i.e. not null or undefined)
  • The RegisterComponent should do the same
  • The HomeComponent should react by navigating to the auth/login route when the user() signal becomes null

See if you can implement this before continuing.

private router = inject(Router);
constructor() {
effect(() => {
if (this.authService.user()) {
this.router.navigate(['home']);
}
});
}
private router = inject(Router);
constructor() {
effect(() => {
if (this.authService.user()) {
this.router.navigate(['home']);
}
});
}
private router = inject(Router);
constructor() {
effect(() => {
if (!this.authService.user()) {
this.router.navigate(['auth', 'login']);
}
});
}

Now if you try to use the application it should actually appear to work correctly (although it will still look ugly). However, if you happen to have logged in before you will automatically be taken to the home route. This is good, but we don’t actually have a way to trigger a log out, so you will be stuck there.

<div class="container">
<mat-toolbar color="primary">
<span class="spacer"></span>
<button mat-icon-button (click)="authService.logout()">
<mat-icon>logout</mat-icon>
</button>
</mat-toolbar>
<app-message-list [messages]="messageService.messages()" />
<app-message-input (send)="messageService.add$.next($event)" />
</div>
imports: [
MessageListComponent,
MessageInputComponent,
MatIconModule,
MatButtonModule,
MatToolbarModule,
],

Handling Re-authentication

If you use the application now it should generally work — as in you should be able to create an account, log in, and log out. All of the redirects should happen as we expect now.

However, you might notice that if you log out and back in the messages will no longer work. That is because of the way our messages$ source is set up in the MessageService:

messages$ = this.getMessages();

Looks innocent enough, but the problem here is that when we log out we will no longer have permission to read the messages in the messages collection in the database. This means that the collectionData stream that getMessages returns is going to error:

ERROR FirebaseError:
false for 'list' @ L6, false for 'list' @ L12

When an observable errors it will no longer emit any more values. That means that even when we log in again and we have permission to access the database again, the stream will still broken and it won’t emit any more values.

To fix this, we can modify our messages$ source slightly.

private authUser$ = toObservable(this.authService.user);
// sources
messages$ = this.getMessages().pipe(
// restart stream when user reauthenticates
retry({
delay: () => this.authUser$.pipe(filter((user) => !!user)),
})
);

The idea here is that we use the retry operator — the purpose of this operator is to retry subscribing to an observable stream after it errors. There are various ways this retry can behave, but one way it can be used is by providing a “notifier” observable stream to delay to tell it when to retry. When this stream emits, it will retry subscribing to the observable stream.

We want the retry to happen when our user becomes authenticated again. We convert our user signal from the AuthService into an observable stream by using the toObservable function from the @angular/core/rxjs-interop package. This is basically the opposite of the toSignal function we have used occasionally.

This authUser$ stream will emit whenever the auth state changes. But we don’t want to retry the stream whenever the auth state changes, we specifically only want to retry when the auth state changes to a valid user. That is why we add the filter to filter out any null values for the authUser$ stream.

This means that now it will only emit when the user becomes authenticated again, and it is then that we want to retry the stream.

After making this change, you should see that the messages work fine again.

Create a Guard for the Home Route

A route guard is probably easier to implement than it sounds — essentially, it is a function that determines whether a user can activate a particular route or not. Let’s just create it and see how it works.

import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../data-access/auth.service';
export const isAuthenticatedGuard = (): CanActivateFn => {
return () => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.user()) {
return true;
}
return router.parseUrl('auth/login');
};
};

The basic idea here is that if this function returns true the user will be able to activate this route. If it returns false they can not.

All we do is check our user() signal from the AuthService to determine this. But, we are doing something a little bit interesting here. Rather than just returning false if they can not access this route, we do this:

return router.parseUrl('auth/login');

We don’t want to just prevent them from accessing the home route, we want to redirect them to the login page. Using parseUrl here is a way for us to directly tell Angular where the resulting navigation should be. We could technically use a normal navigate call in here, but this is a side effect that will then trigger the routing process for Angular again — parseUrl is just a more direct way to get the result we want in the route guard.

To use our route guard, we can just attach it to any route we want to protect with this function.

import { Routes } from '@angular/router';
import { isAuthenticatedGuard } from './shared/guards/auth.guard';
export const routes: Routes = [
{
path: 'auth',
loadChildren: () => import('./auth/auth.routes').then((m) => m.AUTH_ROUTES),
},
{
path: 'home',
canActivate: [isAuthenticatedGuard()],
loadComponent: () => import('./home/home.component'),
},
{
path: '',
redirectTo: 'auth',
pathMatch: 'full',
},
];

If you make sure you are logged out now, and then try to directly access the /home route by entering it into the URL bar, you will see the login page gets loaded instead.

Special Handling for the Login Route

Now we need to handle what happens if the user is logged in and they try to access the login page (which will happen by default when they load the application).

We could do essentially the same thing here with another route guard. Create a route guard for the login route, but just have the opposite check — if a user is already authenticated then they get “kicked” to the logged in view.

That would be a worthwhile exercise actually if you want to try and implement that yourself.

But, we are ultimately going to go with a slightly different approach for the login page.

If you make sure you are logged in and then try to access the root route / you will see that it actually already does redirect. However, unlike when we used the route guard, we still see the login page momentarily.

We are going to keep things this way because the downside of the route guard is that nothing can happen until our authState observable from Firebase emits — this is what tells us if the user is authenticated or not. So, there is going to be some delay, and if we use a route guard for our login page it means the user will see nothing until that stream emits its value.

To improve the perceived performance here, we are going to immediately display the login page, and redirect automatically once the check finishes (again, this is already happening).

We can improve the UX a bit here though by displaying a loading spinner whilst this check happens. It would be a bit annoying to see the login form, go to fill it in thinking you aren’t authenticated, and then all of a sudden you get thrown into a different page.

<div class="container gradient-bg">
@if(authService.user() === null){
<app-login-form
[loginStatus]="loginService.status()"
(login)="loginService.login$.next($event)"
/>
<a routerLink="/auth/register">Create account</a>
} @else {
<mat-spinner diameter="50" />
}
</div>

Now if the user is already logged in they will see a brief loading spinner and then be taken to the logged in view. If they are not authenticated, they will briefly see a spinner and then see the login form.

This doesn’t change the overall performance, but if things are happening on screen it can make the application feel a lot faster and smoother.

And that’s it! Our application is mostly completed at this point… it just looks terrible. In the next lesson, we are going to add a few final touches.