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.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 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 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.