Auth Service — JWT Storage, Token Refresh and Session Management

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);
  }
}
Note: 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.
Tip: 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.
Warning: Never decode a JWT to check permissions on the client side only. The JWT claims in 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.

🧠 Test Yourself

A user refreshes the browser page. The access token (stored in a Signal) is lost. The refresh token is in an httpOnly cookie. What happens next with a correctly implemented session restore?