We use cookies to enhance your experience on the site
CodeWorlds

The three gate guards - authentication

A samurai fortress has one gate and three guards who together decide who walks through it. An Angular application is built the same way, and this lesson introduces all three roles: the service holds the key (the token), the interceptor stamps every request to the server with it, and the guard lets nobody onto a protected route without that key. We take them one at a time.

One rule carries over from the previous lesson.

DomSanitizer.bypassSecurityTrustHtml()
is used only for trusted HTML when we deliberately want to bypass sanitization - not always when displaying HTML, not for every input from the user, and it is not deprecated either. Angular sanitizes by default, and you step around that default consciously. The gate works on the same instinct: trust nothing you have not checked.

The first guard: the service holds the key

When you log in, the server issues a token - a digital key that proves who you are.

AuthService
keeps that key and knows whether you are logged in.

1@Injectable({ providedIn: 'root' })
2export class AuthService {
3  private http = inject(HttpClient);
4  private currentUser = signal<User | null>(null);
5  readonly isLoggedIn = computed(() => !!this.currentUser());
6
7  login(credentials: LoginCredentials): Observable<AuthResponse> {
8    return this.http.post<AuthResponse>('/api/auth/login', credentials).pipe(
9      tap(response => {
10        localStorage.setItem('access_token', response.accessToken);
11        this.currentUser.set(response.user);
12      })
13    );
14  }
15
16  getToken(): string | null {
17    return localStorage.getItem('access_token');
18  }
19}

Notice two things. The token goes into

localStorage
so that it survives a page reload - otherwise every F5 would log the user out. And
isLoggedIn
is a
computed
derived from the
currentUser
signal, so the whole application reacts to a login automatically: the user changes,
isLoggedIn
changes, every view that reads it refreshes. The three lines that build this reactive state must appear in that order -
private http = inject(HttpClient);
first, then
private currentUser = signal<User | null>(null);
, and only then
readonly isLoggedIn = computed(() => !!this.currentUser());
, because a computed can derive only from a signal that already exists. The same service also holds
logout()
, which clears the stored keys, and
refreshToken()
, which trades a stored refresh token for a fresh access token - the method the next guard leans on.

The second guard: the interceptor stamps the requests

The server asks for the key on every request. Instead of attaching the token by hand in a hundred places, an interceptor catches each outgoing request and adds the header with the token.

1export const authInterceptor: HttpInterceptorFn = (req, next) => {
2  const authService = inject(AuthService);
3  const token = authService.getToken();
4
5  if (token) {
6    req = req.clone({
7      headers: req.headers.set('Authorization', `Bearer ${token}`)
8    });
9  }
10  return next(req);
11};

The critical part is

req.clone
- requests in Angular are immutable, so you never add the header to the original; you build a copy of it with
Authorization
attached. One place in the code, and the token reaches every request in the whole application. That is why an interceptor beats writing the header by hand, and why its type is
HttpInterceptorFn
- a plain function that uses
inject()
. Public endpoints can be waved through early with a check on
req.context.get(SKIP_AUTH)
.

The real strength of the interceptor shows when the token expires. Instead of logging the user out, you can quietly refresh the key and repeat the request.

1return next(req).pipe(
2  catchError((error: HttpErrorResponse) => {
3    if (error.status === 401) {          // token expired
4      return authService.refreshToken().pipe(
5        switchMap(response => {
6          const newReq = req.clone({
7            headers: req.headers.set('Authorization', `Bearer ${response.accessToken}`)
8          });
9          return next(newReq);           // retry with the new key
10        }),
11        catchError(() => {
12          authService.logout();          // the refresh failed too - log out
13          return throwError(() => error);
14        })
15      );
16    }
17    return throwError(() => error);
18  })
19);

Follow the logic, because this is what

authInterceptor
does when the server returns status
401
: the status means the key is no longer valid, so it tries to refresh the token via
refreshToken()
, and logs out on failure
.
switchMap
repeats the original request with the new token and the user notices nothing. Only when the refresh fails as well does the inner
catchError
log the user out. It never reloads the whole page, never retries the request indefinitely, and of course never deletes the user account - it swaps a dead key for a live one. That is the difference between an app that throws you back to the login form every hour and one that simply keeps running.

The third guard: the guard watches the route

The interceptor protects requests, but the route itself - the admin panel, say - is watched by a guard: a function that must return

true
before Angular lets you onto the page.

1export const authGuard: CanActivateFn = (route, state) => {
2  const authService = inject(AuthService);
3  const router = inject(Router);
4
5  if (authService.isLoggedIn()) {
6    return true;
7  }
8  router.navigate(['/login'], { queryParams: { returnUrl: state.url } });
9  return false;
10};
11
12export const roleGuard: CanActivateFn = (route) => {
13  const authService = inject(AuthService);
14  const requiredRoles = route.data['roles'] as string[];
15  const user = authService.currentUser();
16  return !!user && requiredRoles.some(role => user.roles.includes(role));
17};

The two guards play different parts.

authGuard
checks whether you are logged in and, if you are not, redirects to the login page while saving
returnUrl
, so that after logging in you land where you meant to go.
roleGuard
goes further and asks who you are: do you hold the required role. Both are typed as
CanActivateFn
- a function using
inject()
- and that is the modern shape of a guard in Angular 19; there is no
@Guard()
decorator, no
RouteGuardService
, and the old
CanActivate
class implementing an interface now belongs to legacy code. A guard may also answer with a
UrlTree
instead of a bare
false
:
router.createUrlTree(['/access-denied'])
builds one and Angular reads it as "no, go here instead". You plug the guards into a route together.

1const routes: Routes = [
2  {
3    path: 'admin',
4    canActivate: [authGuard, roleGuard],
5    data: { roles: ['admin', 'moderator'] },
6    loadComponent: () => import('./admin.component')
7  }
8];

Angular runs both guards in order and opens

/admin
only once both return
true
- first "are you logged in", then "are you an admin or a moderator". Read the route fields from the top:
path: 'admin',
names it,
canActivate: [authGuard, roleGuard],
puts the two checks in front of it,
data: { roles: ['admin'] },
hands the guard the list of allowed roles, and
loadComponent: () => import('./admin.component')
pulls in the page only after the guards agree. Because the roles travel in
data
, the same
roleGuard
serves any route - you change only the list.

Remember the three guards of this lesson: the service holds the key, the interceptor stamps the requests with it and quietly refreshes it, and the guard watches the entrance to the route. Together they form one gate that nobody slips through.

Go to CodeWorlds