A production Angular authentication service manages the full session lifecycle: login, token storage, automatic refresh before expiry, role-based access, and graceful logout. The recommended pattern for Angular SPAs stores the access token in memory (an Angular Signal) — it is XSS-safe because JavaScript from other origins cannot access Angular’s internal state. The refresh token is stored in an httpOnly cookie set by the server — JavaScript cannot read it at all, providing the strongest protection. On page refresh, the app silently calls the refresh endpoint to restore the session using the cookie.
Complete AuthService
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import { firstValueFrom } from 'rxjs';
export interface JwtClaims {
sub: string; // user ID
email: string;
displayName: string;
roles: string[];
exp: number; // expiry timestamp (seconds since epoch)
}
export interface AuthResponse {
accessToken: string;
expiresIn: number; // seconds until expiry
// refresh token is set as httpOnly cookie by the server — not in response body
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private http = inject(HttpClient);
private router = inject(Router);
private baseUrl = inject(API_BASE_URL);
// ── Access token in memory — XSS-safe ─────────────────────────────────
private _accessToken = signal<string | null>(null);
private _claims = signal<JwtClaims | null>(null);
private _refreshTimer?: ReturnType<typeof setTimeout>;
// ── Public read-only state ─────────────────────────────────────────────
readonly accessToken = this._accessToken.asReadonly();
readonly currentUser = this._claims.asReadonly();
readonly isLoggedIn = computed(() => this._claims() !== null);
readonly displayName = computed(() => this._claims()?.displayName ?? '');
readonly userRoles = computed(() => this._claims()?.roles ?? []);
hasRole(role: string): boolean { return this.userRoles().includes(role); }
hasAnyRole(...roles: string[]): boolean {
return roles.some(r => this.userRoles().includes(r));
}
// ── Login ─────────────────────────────────────────────────────────────
async login(email: string, password: string): Promise<void> {
const response = await firstValueFrom(
this.http.post<AuthResponse>(`${this.baseUrl}/api/auth/login`,
{ email, password },
{ withCredentials: true } // ensures httpOnly cookie is received/sent
)
);
this.setSession(response);
}
// ── Logout ────────────────────────────────────────────────────────────
async logout(): Promise<void> {
try {
// Tell the server to invalidate the refresh token cookie
await firstValueFrom(
this.http.post(`${this.baseUrl}/api/auth/logout`, {},
{ withCredentials: true })
);
} finally {
this.clearSession();
this.router.navigate(['/auth/login']);
}
}
// ── Silent refresh on app startup ─────────────────────────────────────
async tryRestoreSession(): Promise<boolean> {
try {
const response = await firstValueFrom(
this.http.post<AuthResponse>(`${this.baseUrl}/api/auth/refresh`, {},
{ withCredentials: true }) // sends the httpOnly refresh cookie
);
this.setSession(response);
return true;
} catch {
return false; // no valid session — user must log in
}
}
// ── Refresh access token ───────────────────────────────────────────────
refreshToken(): Promise<string> {
return firstValueFrom(
this.http.post<AuthResponse>(`${this.baseUrl}/api/auth/refresh`, {},
{ withCredentials: true })
).then(response => {
this.setSession(response);
return response.accessToken;
});
}
// ── Private helpers ────────────────────────────────────────────────────
private setSession(response: AuthResponse): void {
this._accessToken.set(response.accessToken);
this._claims.set(this.decodeJwt(response.accessToken));
this.scheduleRefresh(response.expiresIn);
}
private clearSession(): void {
this._accessToken.set(null);
this._claims.set(null);
if (this._refreshTimer) clearTimeout(this._refreshTimer);
}
private decodeJwt(token: string): JwtClaims | null {
try {
const payload = token.split('.')[1];
return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
} catch {
return null;
}
}
private scheduleRefresh(expiresInSeconds: number): void {
if (this._refreshTimer) clearTimeout(this._refreshTimer);
// Refresh 60 seconds before expiry
const refreshInMs = Math.max(0, (expiresInSeconds - 60) * 1000);
this._refreshTimer = setTimeout(() => this.refreshToken(), refreshInMs);
}
}
withCredentials: true on HTTP requests is required for the browser to include httpOnly cookies in cross-origin requests. Without it, the browser’s CORS security policy omits cookies even when the server has set them. This option must be set on all requests that need authentication: the initial login, refresh calls, and all API requests. The auth interceptor handles adding it to authenticated requests automatically — you do not need to add it manually to every individual API call.authService.tryRestoreSession() as an Angular APP_INITIALIZER function that runs before the application renders. This silently tries to restore the session from the httpOnly refresh cookie — if successful, the user lands directly on their requested page; if not, they see the login page. Without this, every page refresh logs the user out (the in-memory access token is lost on refresh) even though a valid refresh cookie exists. The APP_INITIALIZER hook is the correct place for session restoration.decodeJwt() are not cryptographically verified on the Angular side — only the server verifies the signature. Client-side JWT decoding is only for UX purposes: reading the user’s display name, roles, and expiry to show/hide UI elements and schedule refresh. All real authorisation decisions (who can access what data) happen on the ASP.NET Core API side. Hiding a button in Angular is a UX courtesy, not a security control.App Initializer for Session Restore
// ── app.config.ts — restore session before rendering ─────────────────────
import { APP_INITIALIZER, ApplicationConfig } from '@angular/core';
function initAuth(authService: AuthService) {
return () => authService.tryRestoreSession();
}
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
{
provide: APP_INITIALIZER,
useFactory: initAuth,
deps: [AuthService],
multi: true, // multiple initializers can coexist
},
],
};
Common Mistakes
Mistake 1 — Forgetting withCredentials: true on auth requests (cookies not sent)
❌ Wrong — refresh endpoint called without { withCredentials: true }; browser omits httpOnly cookie; 401 returned.
✅ Correct — all auth requests (login, logout, refresh) and all API requests require withCredentials: true for cookie auth.
Mistake 2 — Trusting JWT claims for authorisation instead of just UX
❌ Wrong — Angular checks decoded JWT role claim before allowing an admin action; user edits the stored token in memory.
✅ Correct — Angular hides/shows UI based on JWT claims; ASP.NET Core enforces authorisation on every API request.