Verifying the complete JWT authentication pipeline requires checking each layer systematically — not just that “it works” but that the security properties hold: tokens are stored correctly, cookies are httpOnly, HTTPS is enforced, and role-based access is properly guarded. A debug overlay in development mode makes the token state visible without exposing it in production.
End-to-End Auth Verification
// ── Debug overlay — shows auth state in development ────────────────────────
@Component({
selector: 'app-auth-debug',
standalone: true,
imports: [DatePipe],
template: `
@if (config.enableDebugTools) {
<div class="auth-debug-overlay">
<h4>Auth Debug</h4>
@if (auth.isLoggedIn()) {
<p>User: {{ auth.displayName() }}</p>
<p>Roles: {{ auth.userRoles().join(', ') }}</p>
<p>Token expires: {{ tokenExpiry() | date:'HH:mm:ss' }}</p>
<p>Expires in: {{ secondsUntilExpiry() }}s</p>
<button (click)="forceRefresh()">Force Refresh</button>
<button (click)="clearToken()">Clear Token (simulate expiry)</button>
} @else {
<p>Not authenticated</p>
}
</div>
}
`,
styles: [`
.auth-debug-overlay {
position: fixed; bottom: 1rem; left: 1rem;
background: rgba(0,0,0,0.85); color: #0f0;
font-family: monospace; font-size: 12px;
padding: 1rem; border-radius: 8px; z-index: 9999;
}
`],
})
export class AuthDebugComponent {
protected auth = inject(AuthService);
protected config = inject(APP_CONFIG);
private refresh$ = interval(1000);
tokenExpiry = computed(() => {
const exp = this.auth.currentUser()?.exp;
return exp ? new Date(exp * 1000) : null;
});
secondsUntilExpiry = toSignal(
this.refresh$.pipe(
map(() => {
const exp = this.auth.currentUser()?.exp;
return exp ? Math.max(0, exp - Math.floor(Date.now() / 1000)) : 0;
})
), { initialValue: 0 }
);
forceRefresh() { this.auth.refreshToken().subscribe(); }
clearToken() { (this.auth as any)._accessToken.set(null); } // dev only
}
Verification Checklist
-- ── Browser DevTools verification steps ──────────────────────────────────
-- 1. LOGIN REQUEST (Network tab)
-- POST /api/auth/login
-- Request body: { "email": "...", "password": "..." }
-- Response: 200 OK
-- Body: { "accessToken": "eyJ...", "expiresIn": 900, "displayName": "..." }
-- Set-Cookie: refreshToken=...; HttpOnly; Secure; SameSite=Lax; Path=/api/auth
-- 2. VERIFY HTTPONLY COOKIE (Application tab → Cookies)
-- refreshToken cookie:
-- HttpOnly: ✅ (cannot be read by document.cookie)
-- Secure: ✅ (only sent over HTTPS)
-- Path: /api/auth (only sent to auth endpoints)
-- Verify: open Console, type document.cookie → refreshToken NOT visible
-- 3. SUBSEQUENT API REQUEST (Network tab)
-- GET /api/posts (or any authenticated endpoint)
-- Request headers:
-- Authorization: Bearer eyJ... (access token from memory)
-- Cookie: refreshToken=... (automatically sent by browser for /api/auth)
-- Response: 200 OK with post data
-- 4. TOKEN EXPIRY SIMULATION
-- In debug overlay: click "Clear Token"
-- Make any API call → 401 returned
-- Network tab shows: POST /api/auth/refresh (interceptor's auto-refresh)
-- Request: Cookie: refreshToken=... (httpOnly cookie sent automatically)
-- Response: new accessToken
-- Original request retried with new token → 200 OK
-- 5. ADMIN ROUTE GUARD (with non-Admin user)
-- Navigate to /admin in browser address bar
-- → Redirected to /auth/login?returnUrl=/admin (not logged in)
-- Login as regular user (no Admin role)
-- → Redirected to /forbidden (logged in but wrong role)
-- Admin menu item: not visible (HasRole directive hides it)
refreshToken cookie has HttpOnly: ✅ checked — this means JavaScript cannot read it. Open the Console and type document.cookie — the refreshToken should NOT appear in the output. If it does appear, the cookie’s httpOnly flag was not set correctly on the API response. This single DevTools check confirms the most critical security property of the auth implementation.AuthDebugComponent to AppComponent template conditionally: it only renders when config.enableDebugTools is true (development environment). The “Force Refresh” button lets you test the token refresh flow without waiting for the 15-minute expiry. The countdown timer shows exactly how many seconds until the scheduled auto-refresh fires. The “Clear Token” button simulates token expiry, letting you test the interceptor’s 401-handling and refresh flow on demand.@if (config.enableDebugTools) check prevents rendering, but Angular’s tree shaker may still include the component code in the bundle. To completely exclude debug components from production bundles, use lazy loading or conditionally include them only in development-specific module imports. Exposing JWT claim details (user ID, roles, expiry) in a UI overlay would be a significant information disclosure in production.Complete Auth Flow Summary
| Step | What Happens | Security Mechanism |
|---|---|---|
| Page Load | APP_INITIALIZER calls refresh endpoint | httpOnly cookie auto-sent |
| Login | POST credentials → receive JWT + set cookie | HTTPS, httpOnly cookie |
| API Request | Interceptor adds Bearer token | Token in memory (XSS-safe) |
| Token Expired | 401 → refresh → retry original request | Single refresh call (queuing) |
| Admin Route | authGuard + adminGuard check claims | API [Authorize(Roles)] too |
| Logout | Revoke refresh token + clear memory | Cookie revoked server-side |
Common Mistakes
Mistake 1 — Not checking DevTools Application tab for httpOnly cookie (assuming it works)
❌ Wrong — assuming the cookie is httpOnly without verifying; a missing HttpOnly flag allows XSS token theft.
✅ Correct — always verify in DevTools Application tab: HttpOnly ✅, Secure ✅, correct Path and SameSite.
Mistake 2 — Debug overlay visible in production (information disclosure)
❌ Wrong — debug overlay renders in production because environment config was not switched correctly.
✅ Correct — verify production build with ng build --configuration=production; check that enableDebugTools is false.