Posts

Modern Auth Patterns for Angular Frontends (Beyond “Just Add JWT”)

Stop storing JWTs in localStorage. Use OIDC with a BFF or reverse proxy, short-lived server-side tokens, HttpOnly cookies, and Angular interceptors and guards that do not pretend to be your security boundary.
Table of Contents

For years, the default Angular auth tutorial looked like this: call /login, get a JWT, stash it in localStorage, attach Authorization: Bearer … from an interceptor, and sprinkle a couple of route guards on top. It ships. It demos well. And it quietly trains teams to treat the browser as a safe place for long-lived credentials.

It is not.

In 2026 the guidance has converged, and it is blunt:

  • Prefer OAuth 2.0 / OIDC with a reverse proxy or dedicated auth provider — not a hand-rolled password form that mints forever-tokens for the SPA.
  • Prefer short-lived access tokens (and refresh handled outside the SPA) over long-lived credentials in the browser.
  • Prefer HttpOnly, Secure, SameSite cookies over storing sensitive JWTs in localStorage or sessionStorage.

This post is the practical shape of that advice for Angular apps: why localStorage JWTs lose to XSS, how a Backend-for-Frontend (BFF) or reverse-proxy session keeps tokens off the client, and what Angular still owns — interceptors, CSRF headers, and guards that improve UX without pretending to enforce authorization.

The Short Version

If JavaScript can read your access token, so can an XSS payload.

That is the whole argument against browser-visible JWTs. An HttpOnly session cookie cannot be exfiltrated with localStorage.getItem('token'). A BFF (or reverse proxy acting as one) can run the OIDC authorization code flow with PKCE, hold access and refresh tokens server-side, and give the browser only an opaque session cookie. Angular then calls same-origin APIs with credentials. Tokens never appear in application code.

Route guards and interceptors still matter — for navigation, refresh UX, and attaching CSRF headers — but authorization is enforced on the server. Always.

Why “Just Add JWT” Failed in the Browser

The classic SPA pattern looks secure because JWTs feel cryptographic. The failure mode is not signature verification. It is token exfiltration.

XSS turns storage into theft

Any script that runs in your origin can read localStorage and sessionStorage. That includes:

  • a compromised npm dependency that runs at runtime,
  • a stored XSS in markdown, comments, or rich text your app renders,
  • a framework sanitizer bug (see the recent Angular SVG/MathML advisory wave in Why Client-Side Frameworks Need Security Updates),
  • a malicious browser extension with page access (less common in threat models, still unpleasant).

Once the payload has the JWT, it can call your APIs from anywhere until the token expires. If that token is long-lived, the blast radius is measured in days or weeks, not minutes.

HttpOnly cookies change the game. Malicious script can still act as the user in the current browser session (that is XSS), but it cannot casually copy a bearer token into an attacker-controlled environment and replay it later from a laptop across the world. Session fixation, CSRF, and session lifetime become the problems you design for — and those are well-understood cookie problems, not “hope our XSS never happens” problems.

Bearer tokens in JS also fight the platform

When Angular attaches Authorization from memory or storage, every request becomes a custom credential flow. Cookies, by contrast, are same-site aware, participate in browser privacy rules, and pair naturally with Angular’s built-in XSRF support when you stay same-origin.

The IETF work on OAuth 2.0 for Browser-Based Applications has been pushing the industry the same direction: treat the browser as a public client that should not hold refresh tokens if you can avoid it, and prefer a backend component that does.

The Modern Shape: OIDC + BFF (or Reverse Proxy)

Think in three layers:

1
2
3
4
5
6
Browser (Angular)
  └─ same-origin /bff/* and /api/*
        └─ BFF / reverse proxy  (confidential OAuth client)
              ├─ talks to IdP (Auth0, Keycloak, Entra ID, …)
              ├─ stores access + refresh tokens server-side
              └─ proxies APIs with the access token

What the BFF does

  1. Starts the OIDC authorization code flow with PKCE.
  2. Exchanges the code for tokens.
  3. Stores tokens in a server-side session (Redis, encrypted server cookie store, etc.).
  4. Sets a browser cookie that is at least:
    • HttpOnly — not readable from JavaScript,
    • Secure — HTTPS only in production,
    • SameSite=Lax or Strict — limits cross-site sending.
  5. On each API call from Angular, loads the session, refreshes the access token if needed, and calls downstream services.

Managed IdPs and API gateways often package this as a documented “BFF” or “token handler” pattern. You do not have to invent cryptography. You do have to stop shipping the access token into the SPA bundle’s runtime storage.

What Angular does not do anymore

  • No localStorage.setItem('access_token', …).
  • No silent iframe renew that parks refresh tokens in the browser.
  • No interceptor whose main job is setHeaders: { Authorization: \Bearer ${token}` }` for first-party APIs.

Those patterns still appear in older SPA OIDC libraries when the SPA is the OAuth client. They are a deliberate tradeoff. For first-party Angular apps talking to your own APIs, the BFF tradeoff is usually the better default in 2026.

Angular Still Has Real Work

Removing tokens from the browser does not remove frontend auth code. It changes what that code is responsible for.

1. Call APIs with credentials

Same-origin requests should include cookies:

1
this.http.get('/api/profile', { withCredentials: true });

Or set withCredentials once in an interceptor so individual calls cannot forget.

Cookies mean CSRF is back on the checklist. SameSite helps a lot; it is not a complete substitute for defense in depth on state-changing requests.

A common pattern:

  • BFF sets a readable CSRF cookie (not HttpOnly).
  • Angular sends the value back as X-CSRF-Token on POST / PUT / PATCH / DELETE.
  • BFF rejects mismatches.

Angular’s own HttpClient XSRF support covers the classic cookie-to-header pattern for same-origin calls when configured for your cookie and header names. The companion uses an explicit interceptor so the flow is obvious when teaching.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
export const csrfInterceptor: HttpInterceptorFn = (req, next) => {
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
    return next(req);
  }

  const csrf = inject(AuthService).csrfToken(); // from non-HttpOnly cookie
  if (!csrf) {
    return next(req.clone({ withCredentials: true }));
  }

  return next(
    req.clone({
      withCredentials: true,
      setHeaders: { 'X-CSRF-Token': csrf },
    })
  );
};

3. Interceptor for 401 → refresh → retry

Access windows should be short. When the BFF says the access session expired, Angular should ask the BFF to refresh — not dig a refresh token out of storage.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthService);
  const router = inject(Router);

  const isAuthEndpoint = /\/bff\/(login|refresh|logout|csrf|session)/.test(req.url);

  return next(req.clone({ withCredentials: true })).pipe(
    catchError((error: unknown) => {
      if (!(error instanceof HttpErrorResponse) || error.status !== 401 || isAuthEndpoint) {
        return throwError(() => error);
      }

      return auth.refreshAccess().pipe(
        switchMap((ok) => {
          if (!ok) {
            void router.navigate(['/login'], {
              queryParams: { reason: 'session_expired' },
            });
            return throwError(() => error);
          }
          return next(req.clone({ withCredentials: true }));
        })
      );
    })
  );
};

Register both interceptors with provideHttpClient(withInterceptors([csrfInterceptor, authInterceptor])).

Notice what is missing: there is no bearer token variable. Refresh is an HTTP call to your BFF. The new access token stays on the server.

4. Auth and role guards (UX only)

Functional guards keep users out of screens they should not see. They are not a security control.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
export const authGuard: CanActivateFn = () => {
  const auth = inject(AuthService);
  const router = inject(Router);
  return auth.isAuthenticated()
    ? true
    : router.createUrlTree(['/login'], { queryParams: { reason: 'auth_required' } });
};

export const roleGuard = (role: string): CanActivateFn => {
  return () => {
    const auth = inject(AuthService);
    const router = inject(Router);
    if (!auth.isAuthenticated()) {
      return router.createUrlTree(['/login']);
    }
    return auth.hasRole(role) ? true : router.createUrlTree(['/forbidden']);
  };
};

Wire them on routes:

1
2
3
4
5
{
  path: 'admin',
  component: AdminPageComponent,
  canActivate: [authGuard, roleGuard('admin')],
}

Then enforce the same rule on /api/admin. If someone bypasses the router (devtools, old PWA shell, crafted fetch), the API must still return 403. Guards hide buttons and prevent awkward flashes of forbidden UI. Servers stop unauthorized work.

Migrating Off localStorage JWTs

A pragmatic path for an existing Angular app:

  1. Introduce a BFF or gateway in front of your APIs (or enable your IdP’s official BFF / token-handler mode). Keep the SPA on the same site as the BFF so cookies are first-party.
  2. Move login from “POST credentials → JWT in JSON” to “redirect to IdP → BFF sets session cookie.”
  3. Delete token storage in the SPA. Search for localStorage, sessionStorage, and Authorization builders. Remove them for first-party calls.
  4. Switch HttpClient to withCredentials: true and add CSRF handling.
  5. Shorten access lifetime on the server. Implement BFF refresh. Point your 401 interceptor at that endpoint.
  6. Keep guards, but audit every sensitive API for server-side authz.
  7. Rotate any tokens that ever lived in browser storage — treat them as potentially leaked.

You can migrate route-by-route. The hard part is usually cookie domains, CORS, and deciding that the SPA is no longer an OAuth client. Once that decision is made, Angular code gets simpler.

What the Companion Demonstrates

The angular-modern-auth example is intentionally small:

  • a teaching BFF that issues HttpOnly sid cookies and a readable CSRF cookie,
  • login that never returns access/refresh tokens to the browser,
  • short access TTL so you can watch refresh happen,
  • Angular interceptors and guards matching the snippets above,
  • /api/profile and /api/admin that enforce session and roles on the server.

Run it locally (npm start in that folder), sign in, and inspect DevTools → Application. You should see cookies — and an empty token shelf in Local Storage. That empty shelf is the point.

Final Thought

“Just add JWT” was a convenient story for SPAs when everyone wanted a pure static frontend and a stateless API. The threat model did not age well. XSS is still with us. Supply-chain script is still with us. Framework sanitizers still ship CVEs.

Modern Angular auth is less about decorating HttpClient with bearer tokens and more about keeping credentials out of the JavaScript heap. Use OIDC. Put a BFF or reverse proxy in front. Prefer short-lived server-side tokens and HttpOnly cookies. Keep interceptors and guards for the UX and CSRF plumbing they are good at — and let the server remain the only component allowed to say “yes.”

Further Reading & References