Angular Auth Patterns
โฑ๏ธ ~6-minute bite ยท solve the sandbox to master
5-Year-Old Metaphor
โ The physical, real-world picture. No jargon.โ๏ธ HTTP interceptor = passport control at the border. Every outbound request shows its passport (Bearer token). Border control (interceptor) stamps it before it departs. If the passport is expired, the traveler visits the visa office (refresh endpoint) and re-queues โ invisibly, from the app's perspective.
๐
AuthService
Issues and revokes tokens. Holds user$ Observable. The only place the token lives.
๐
HttpInterceptor
Stamps every request. Handles 401 silently. Clones requests โ never mutates.
๐ง
Route Guards
Blocks navigation without a valid session. Returns UrlTree to redirect cleanly.
RxJS operators used in auth flows
Interactive Sandbox
โ Move something, see it react instantly.Pattern
Interceptor pipeline
| 1 | import { Injectable, inject } from '@angular/core'; |
| 2 | import { HttpClient } from '@angular/common/http'; |
| 3 | import { BehaviorSubject, Observable, tap, map } from 'rxjs'; |
| 4 | import { Router } from '@angular/router'; |
| 5 | ย |
| 6 | export interface AuthUser { id: string; email: string; role: string } |
| 7 | ย |
| 8 | @Injectable({ providedIn: 'root' }) |
| 9 | export class AuthService { |
| 10 | private readonly http = inject(HttpClient); |
| 11 | private readonly router = inject(Router); |
| 12 | ย |
| 13 | // Private: only this service touches the token |
| 14 | private accessToken: string | null = null; |
| 15 | ย |
| 16 | // Public stream: components subscribe to know who's logged in |
| 17 | private userSubject = new BehaviorSubject<AuthUser | null>(null); |
| 18 | readonly user$: Observable<AuthUser | null> = this.userSubject.asObservable(); |
| 19 | ย |
| 20 | // Derived: synchronous snapshot for guards and interceptors |
| 21 | get isAuthenticated(): boolean { return !!this.accessToken; } |
| 22 | get token(): string | null { return this.accessToken; } |
| 23 | ย |
| 24 | login(email: string, password: string): Observable<void> { |
| 25 | return this.http |
| 26 | .post<{ user: AuthUser; accessToken: string }>('/api/auth/login', { email, password }) |
| 27 | .pipe( |
| 28 | tap(({ user, accessToken }) => { |
| 29 | this.accessToken = accessToken; // in-memory only โ no localStorage |
| 30 | this.userSubject.next(user); |
| 31 | }), |
| 32 | map(() => void 0), |
| 33 | ); |
| 34 | } |
| 35 | ย |
| 36 | logout(): void { |
| 37 | this.accessToken = null; |
| 38 | this.userSubject.next(null); |
| 39 | // Revoke refresh token on server (httpOnly cookie cleared by server) |
| 40 | this.http.post('/api/auth/logout', {}, { withCredentials: true }).subscribe(); |
| 41 | this.router.navigate(['/login']); |
| 42 | } |
| 43 | ย |
| 44 | refreshToken(): Observable<string> { |
| 45 | return this.http |
| 46 | .post<{ accessToken: string }>('/api/auth/refresh', {}, { withCredentials: true }) |
| 47 | .pipe( |
| 48 | tap(({ accessToken }) => { |
| 49 | this.accessToken = accessToken; |
| 50 | }), |
| 51 | map(({ accessToken }) => accessToken), |
| 52 | ); |
| 53 | } |
| 54 | } |
When to use
Every Angular app with authentication. providedIn: 'root' makes it a singleton shared across the app. Never instantiate it with new โ always inject. The BehaviorSubject exposes the current user synchronously (getValue()) and reactively (subscribe).
Challenge
Explore all 5 Angular auth patterns. Run the interceptor pipeline to see how token refresh works end-to-end.
Why Should I Care?
โ The exact interview question + the bug it kills.Interview questions
Q: Why inject via constructor instead of direct instantiation?
Angular's DI system manages object lifecycles. providedIn: 'root' creates exactly one instance shared across the whole app โ a singleton. Direct instantiation with new AuthService()creates a separate instance that doesn't share state with the rest of the app and breaks testability. In tests, you replace the injected dependency with a mock โ you can't do that if the class instantiates its own dependencies.
Q: Why clone the request in the interceptor instead of mutating it?
HttpRequest is intentionally immutable in Angular. There is no setter for headers โ the design forces you to call .clone(), which returns a new request with your modifications applied. This prevents interceptors from accidentally interfering with each other โ each interceptor works on its own copy. Immutability also makes debugging easier: the original request object is always unchanged.
Q: Why switchMap for the login form submit?
switchMap cancels the previous Observable when a new one arrives. If a user clicks Submit, then clicks again before the first request completes, switchMap unsubscribes from the first HTTP call and subscribes to the second. Only one request completes. For the login flow this is correct โ the second submit supersedes the first. Use exhaustMap if you want to ignore subsequent clicks while the first is in flight (e.g. payment forms where you never want duplicate submissions).
The Deep Dive
โ Spec refs, engine internals, the minutiae.Full AuthInterceptor with RxJS refresh + retry
The complete pattern: request interceptor attaches the token, response interceptor catches 401, refreshes via httpOnly cookie, queues concurrent 401s, and replays them with the new token. The BehaviorSubject(null) acts as a lock โ other requests filter on non-null and take(1) before retrying.
| 1 | // Full refresh-queue pattern |
| 2 | private isRefreshing = false; |
| 3 | private refreshToken$ = new BehaviorSubject<string | null>(null); |
| 4 | ย |
| 5 | private handle401(req, next) { |
| 6 | if (this.isRefreshing) { |
| 7 | return this.refreshToken$.pipe( |
| 8 | filter((t): t is string => t !== null), |
| 9 | take(1), |
| 10 | switchMap(token => next.handle(this.addToken(req, token))) |
| 11 | ); |
| 12 | } |
| 13 | this.isRefreshing = true; |
| 14 | this.refreshToken$.next(null); |
| 15 | return this.auth.refreshToken().pipe( |
| 16 | switchMap(token => { |
| 17 | this.isRefreshing = false; |
| 18 | this.refreshToken$.next(token); |
| 19 | return next.handle(this.addToken(req, token)); |
| 20 | }), |
| 21 | catchError(err => { this.auth.logout(); return throwError(() => err); }) |
| 22 | ); |
| 23 | } |
withCredentials: true for httpOnly cookie flows
When the refresh token is stored in an httpOnly cookie, the browser only sends it if the request includes withCredentials: true. Without it, cross-origin requests strip all cookies. Set it on the refresh call specifically โ or globally via a separate interceptor for your auth domain only.
| 1 | // Refresh call โ must include credentials |
| 2 | this.http.post<{ accessToken: string }>( |
| 3 | '/api/auth/refresh', |
| 4 | {}, |
| 5 | { withCredentials: true } // sends the httpOnly refresh cookie |
| 6 | ) |
providedIn: 'root' vs feature-level scoping
providedIn: 'root' creates a singleton available everywhere โ correct for AuthService since the whole app shares one auth state. Feature-level scoping (providers: [AuthService] in a route config) creates a fresh instance per route activation โ useful for services that should reset when a feature is destroyed, but wrong for auth. An AuthService scoped to a feature would lose the token when navigating away.
Signal-based auth state with toSignal()
Angular 17+ lets you bridge the Observable world into signals. toSignal(authService.user$) creates a read-only signal that stays in sync with the Observable. Components using OnPush can read it directly in the template โ no async pipe needed. The signal is computed synchronously, so there is no initial null flash.
| 1 | import { toSignal } from '@angular/core/rxjs-interop'; |
| 2 | import { computed } from '@angular/core'; |
| 3 | ย |
| 4 | @Component({ |
| 5 | changeDetection: ChangeDetectionStrategy.OnPush, |
| 6 | template: ` |
| 7 | <span *ngIf="isAdmin()">Admin ๐ก๏ธ</span> |
| 8 | <span>{{ user()?.email }}</span> |
| 9 | `, |
| 10 | }) |
| 11 | export class NavComponent { |
| 12 | private auth = inject(AuthService); |
| 13 | // Bridge Observable โ Signal |
| 14 | user = toSignal(this.auth.user$, { initialValue: null }); |
| 15 | isAdmin = computed(() => this.user()?.role === 'admin'); |
| 16 | } |
Functional guards vs class-based guards
Class-based guards (implementing CanActivate) are deprecated in Angular 15+. Functional guards are preferred: they're tree-shakable (unused guards are eliminated from the bundle), simpler to write (no class boilerplate), and easier to test (plain functions, no TestBed needed). Functional guards also compose better โ multiple guards can be combined in an array and Angular runs them in sequence, short-circuiting on the first false or redirect.
Interview Questions
โ Real questions from real interviews โ with answers.Prevents external code from calling next() and corrupting auth state โ the service is the sole owner.
HttpRequest is immutable by design โ clone() is the only way to modify it, preventing interceptor cross-contamination.
Each 401 independently calls /auth/refresh, causing rotation race conditions that revoke the entire token family.
Returning a UrlTree avoids double navigation and lets the router manage the redirect cleanly.
toSignal bridges Observable to Signal, enabling synchronous template reads and OnPush optimization without async pipe.
Memory Game
โ Quick quiz โ lock the concept in long-term memory.What does finalize() in an RxJS pipe guarantee about loading state?