The refresh token flow is the mechanism that keeps users logged in for days or weeks without requiring them to re-enter their password, while keeping the authentication window short. The Angular side of this flow requires three interconnected pieces: the AuthStore that holds the current access token as a signal, the auth interceptor that attaches the token to outgoing requests and handles 401 responses by transparently refreshing, and the auth guard that protects routes. Getting all three right — and handling edge cases like multiple simultaneous 401s and token rotation — is the complete auth system.
Auth Flow Architecture
| Component | Responsibility | Location |
|---|---|---|
| AuthStore | Holds access token signal, user signal, computed auth state | core/stores/auth.store.ts |
| AuthService | HTTP calls to auth endpoints (login, register, refresh, logout) | core/services/auth.service.ts |
| Auth Interceptor | Attach Bearer token to requests, handle 401 with refresh + retry | core/interceptors/auth.interceptor.ts |
| Auth Guard | Protect routes — redirect to login if not authenticated | core/guards/auth.guard.ts |
| Role Guard | Protect routes by user role — redirect to forbidden if wrong role | core/guards/role.guard.ts |
isRefreshing flag and a BehaviorSubject that queues waiting requests until the single refresh completes (covered in Chapter 12’s interceptor lesson).AuthStore, not in sessionStorage or any browser storage. In-memory storage means the token is automatically cleared on page reload or tab close — users need to log in again (or the app silently refreshes using the HttpOnly cookie). This provides better security than sessionStorage (not accessible to scripts) and correct UX (the refresh flow handles reloads transparently).APP_INITIALIZER that calls the refresh endpoint on startup. If it succeeds, the user is transparently logged in. If it fails (expired or no cookie), the user sees the login page. Without this, users must re-login on every page reload.Complete Angular Auth System
// ── AuthStore — signal-based auth state ───────────────────────────────────
// core/stores/auth.store.ts
import { Injectable, signal, computed, inject } from '@angular/core';
import { Router } from '@angular/router';
export interface AuthUser {
id: string;
name: string;
email: string;
role: 'user' | 'admin' | 'moderator';
}
@Injectable({ providedIn: 'root' })
export class AuthStore {
private router = inject(Router);
// Private writable state
private _accessToken = signal<string | null>(null);
private _user = signal<AuthUser | null>(null);
private _loading = signal(false);
// Public read-only state
readonly accessToken = this._accessToken.asReadonly();
readonly user = this._user.asReadonly();
readonly isLoading = this._loading.asReadonly();
// Derived state
readonly isAuthenticated = computed(() => !!this._accessToken());
readonly isAdmin = computed(() => this._user()?.role === 'admin');
hasRole(role: string | string[]): boolean {
const userRole = this._user()?.role;
if (!userRole) return false;
const roles = Array.isArray(role) ? role : [role];
return roles.includes(userRole);
}
setTokenAndUser(accessToken: string, user: AuthUser): void {
this._accessToken.set(accessToken);
this._user.set(user);
}
setAccessToken(token: string): void {
this._accessToken.set(token);
}
logout(): void {
this._accessToken.set(null);
this._user.set(null);
this.router.navigate(['/auth/login']);
}
setLoading(v: boolean): void { this._loading.set(v); }
}
// ── AuthService — HTTP calls ──────────────────────────────────────────────
// core/services/auth.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { tap } from 'rxjs/operators';
import { API_BASE_URL } from '../tokens';
import { AuthStore, AuthUser }from '../stores/auth.store';
interface AuthResponse {
success: boolean;
data: { accessToken: string; user: AuthUser };
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private http = inject(HttpClient);
private authStore= inject(AuthStore);
private baseUrl = inject(API_BASE_URL);
login(email: string, password: string) {
return this.http.post<AuthResponse>(
`${this.baseUrl}/auth/login`,
{ email, password },
{ withCredentials: true } // send/receive cookies
).pipe(
tap(res => this.authStore.setTokenAndUser(
res.data.accessToken, res.data.user
))
);
}
register(name: string, email: string, password: string) {
return this.http.post<AuthResponse>(
`${this.baseUrl}/auth/register`,
{ name, email, password },
{ withCredentials: true }
).pipe(
tap(res => this.authStore.setTokenAndUser(
res.data.accessToken, res.data.user
))
);
}
refreshToken() {
return this.http.post<{ success: boolean; data: { accessToken: string } }>(
`${this.baseUrl}/auth/refresh`,
{},
{ withCredentials: true } // sends the HttpOnly cookie
).pipe(
tap(res => this.authStore.setAccessToken(res.data.accessToken))
);
}
logout() {
return this.http.post(
`${this.baseUrl}/auth/logout`,
{},
{ withCredentials: true }
).pipe(
tap(() => this.authStore.logout())
);
}
}
// ── Auth guard ────────────────────────────────────────────────────────────
// core/guards/auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { AuthStore } from '../stores/auth.store';
export const authGuard: CanActivateFn = (
_route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
) => {
const authStore = inject(AuthStore);
const router = inject(Router);
if (authStore.isAuthenticated()) return true;
return router.createUrlTree(['/auth/login'], {
queryParams: { returnUrl: state.url },
});
};
// ── Role guard ────────────────────────────────────────────────────────────
export function roleGuard(requiredRole: string | string[]): CanActivateFn {
return () => {
const authStore = inject(AuthStore);
const router = inject(Router);
if (!authStore.isAuthenticated()) {
return router.createUrlTree(['/auth/login']);
}
if (authStore.hasRole(requiredRole)) return true;
return router.createUrlTree(['/forbidden']);
};
}
// ── APP_INITIALIZER — restore session on page reload ─────────────────────
// app.config.ts
import { APP_INITIALIZER } from '@angular/core';
import { AuthService } from './core/services/auth.service';
import { catchError, of } from 'rxjs';
function initAuth(authService: AuthService) {
return () => authService.refreshToken().pipe(
catchError(() => of(null)) // fail silently — user just needs to log in
);
}
export const appConfig = {
providers: [
{
provide: APP_INITIALIZER,
useFactory: initAuth,
deps: [AuthService],
multi: true,
},
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
],
};
// ── Login component ───────────────────────────────────────────────────────
@Component({
selector: 'app-login',
standalone: true,
imports: [ReactiveFormsModule, CommonModule, RouterModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="email" type="email" placeholder="Email">
<input formControlName="password" type="password" placeholder="Password">
<p *ngIf="error()" class="error">{{ error() }}</p>
<button type="submit" [disabled]="loading() || form.invalid">
{{ loading() ? 'Signing in...' : 'Sign In' }}
</button>
</form>
<a routerLink="/auth/register">Create account</a>
`,
})
export class LoginComponent {
private fb = inject(NonNullableFormBuilder);
private authService = inject(AuthService);
private route = inject(ActivatedRoute);
private router = inject(Router);
loading = signal(false);
error = signal<string | null>(null);
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
onSubmit(): void {
this.form.markAllAsTouched();
if (this.form.invalid) return;
this.loading.set(true);
this.error.set(null);
const { email, password } = this.form.getRawValue();
this.authService.login(email, password).subscribe({
next: () => {
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') ?? '/tasks';
this.router.navigateByUrl(returnUrl);
},
error: err => {
this.error.set(err.error?.message ?? 'Login failed. Please try again.');
this.loading.set(false);
},
});
}
}
How It Works
Step 1 — Access Token Lives in Memory, Refresh Token in Cookie
The AuthStore holds the access token in a private signal. It is never written to any browser storage. When the page reloads, the signal is null and the APP_INITIALIZER calls the refresh endpoint. The browser automatically sends the HttpOnly refresh token cookie. If valid, the server returns a new access token which is stored in the signal. The user is transparently re-authenticated without seeing a login page.
Step 2 — withCredentials Sends Cookies Cross-Origin
The Angular frontend (port 4200) and Express backend (port 3000) are different origins in development. Cookies are only sent cross-origin if both the request includes withCredentials: true AND the server responds with Access-Control-Allow-Credentials: true and a specific Access-Control-Allow-Origin (not *). The CORS configuration on the Express server must explicitly allow the Angular origin and credentials.
Step 3 — APP_INITIALIZER Restores Session Before Router Activates
APP_INITIALIZER functions must complete before Angular renders any routes. By returning an Observable from the initialiser, Angular waits for the refresh call to complete or fail before activating any route guard. This means the auth guard sees the correct isAuthenticated() state — not the initial empty state — when it first runs on app load.
Step 4 — The Auth Guard Returns a UrlTree for Redirect
Returning router.createUrlTree(['/auth/login'], { queryParams: { returnUrl: state.url } }) from the guard is a single atomic navigation — it prevents the intended route AND navigates to login in one step, preserving the return URL. After successful login, the component reads the returnUrl query parameter and navigates to the originally requested page.
Step 5 — Role Guard Factory Enables Declarative Role Checking
canActivate: [authGuard, roleGuard('admin')] runs both guards in order. The auth guard checks authentication first. Only if authenticated does the role guard run — checking whether the authenticated user has the required role. This separation of concerns keeps each guard simple and allows combining them freely in route configurations.
Common Mistakes
Mistake 1 — Not sending withCredentials on auth HTTP calls
❌ Wrong — refresh token cookie never sent or received:
this.http.post('/api/v1/auth/refresh', {}) // no withCredentials — cookie not sent!
// Server receives no cookie, returns 401
✅ Correct — always include withCredentials for auth endpoints:
this.http.post('/api/v1/auth/refresh', {}, { withCredentials: true })
Mistake 2 — Not handling the 401 during APP_INITIALIZER
❌ Wrong — unhandled error crashes the app initialisation:
function initAuth(authService: AuthService) {
return () => authService.refreshToken(); // throws if no refresh cookie → app crashes!
}
✅ Correct — catch and return null (user simply sees login page):
return () => authService.refreshToken().pipe(catchError(() => of(null)));
Mistake 3 — Using router.navigate() instead of UrlTree in guards
❌ Wrong — two separate navigations may race:
if (!authenticated) {
router.navigate(['/auth/login']); // starts a new navigation
return false; // also cancels current — two competing navigations
}
✅ Correct — return a UrlTree for a single atomic redirect:
if (!authenticated) {
return router.createUrlTree(['/auth/login']); // single redirect
}
Quick Reference
| Task | Code |
|---|---|
| Store token in memory | private _token = signal<string | null>(null) |
| Is authenticated | isAuthenticated = computed(() => !!this._token()) |
| Login HTTP call | http.post('/auth/login', creds, { withCredentials: true }) |
| Refresh HTTP call | http.post('/auth/refresh', {}, { withCredentials: true }) |
| Restore on reload | APP_INITIALIZER factory calling authService.refreshToken() |
| Auth guard | Return router.createUrlTree(['/login']) if not authenticated |
| Role guard | Return router.createUrlTree(['/forbidden']) if wrong role |
| Clear auth state | this._token.set(null); this._user.set(null) |