Decision. Delete “JWT in localStorage” from the README. Every authority says the same thing: OWASP — “Do not store session identifiers in local storage as the data is always accessible by JavaScript” [1]; the OAuth 2.0 for Browser-Based Apps BCP ranks architectures BFF > token-mediating backend > browser-based public client, and reserves the BFF for “business applications, sensitive applications, and applications that handle personal data” [2]. Ship Authorization Code + PKCE via react-oidc-context ⭐ 1.0k, access token in memory only, refresh token rotated (never in
localStorage) — and plan a .NET BFF (Duende.BFF) as the end-state, since your backend is already ASP.NET Core.
The verdict on localStorage
Not “less ideal” — the wrong tool. A single XSS in any dependency of an Nx monorepo exfiltrates the token instantly and silently. The BCP goes further: with JS execution, an attacker can “inject a hidden iframe and launch a silent Authorization Code flow” [2] — so in-memory storage is a damage limiter, not a fix. That is exactly why the BCP’s top-ranked answer keeps tokens out of the browser: with a BFF “there are no tokens available to extract from the browser”, only an httpOnly SameSite session cookie plus a CSRF header [3].
Refresh tokens: if you keep them browser-side, the AS MUST rotate them on every use or sender-constrain them [2] — Keycloak and OpenIddict both do rotation. Do not design around iframe prompt=none silent renew: third-party-cookie blocking killed it as a general mechanism [4], and it only survives if the IdP is on the same registrable domain as the app (auth.itenium.be / app.itenium.be). Rotating refresh tokens are the sanctioned replacement [5].
Library
| Choice | ⭐ | Verdict |
|---|---|---|
| react-oidc-context | 1.0k | ✓ Pick this. Thin React 19 wrapper over oidc-client-ts; protocol-generic → Keycloak and OpenIddict [6] |
| oidc-client-ts | 1.9k | ✓ The engine underneath; use directly only outside React [7] |
| @axa-fr/react-oidc | 676 | ⚠ Service-worker token isolation — the best pure-SPA hardening if you refuse a BFF [8] |
| keycloak-js | 88 | ✗ Vendor-locked, version-pinned to the server; Keycloak itself deprecated its adapters and points at standard OIDC libraries [9] |
| hand-rolled | — | ✗ You would re-implement PKCE, rotation, single-flight refresh and clock skew. |
Google/Microsoft login belongs in the IdP, not the frontend: Keycloak identity brokering / OpenIddict external providers. Your “reusable login lib” then shrinks to config + guards + a <Can> component — which is the whole point.
Where identity lives (and why not Redux)
- Access token +
isAuthenticated→AuthProvidercontext fromreact-oidc-context. It is already a Context; copying it into Redux/Zustand creates a second copy that goes stale. - Roles & capabilities → not the JWT. Your backend’s own worry about token bloat resolves cleanly here: keep the JWT to
sub+ coarse roles, and serve fine-grained capabilities fromGET /me, cached in TanStack Query ⭐ 50k. Capabilities are server state — the query cache is the correct home, and it gives you revocation without re-login (invalidate['me']).ICurrentUserstays the server-side authority;/meis its projection. - Redux buys nothing here: there is no client-side reducer logic, no time-travel need. Zustand is only warranted if you later add genuinely client-owned session UI state (impersonation banner, tenant switcher) [10].
Refresh, 401s, and query retries
Let the OIDC library own refresh (automaticSilentRenew) — it single-flights. The fetch wrapper reads auth.user?.access_token at call time (never captured at module scope, or you send a stale token). Then disarm the double-retry: an axios interceptor that retries on 401 and TanStack Query retrying 3× produces a refresh stampede [11].
// one place decides; TanStack never retries an auth failure
retry: (count, err) => err.status !== 401 && err.status !== 403 && count < 3
// QueryCache onError: 401 → auth.signinSilent() once → invalidateQueries(); on failure → signinRedirect()
Guards
Route guard in the router’s beforeLoad/loader (redirect on !isAuthenticated, await the ['me'] query so capabilities are present before render). Capability checks are a <Can capability="x"> reading the /me cache. Both are UX only — enforcement stays in Forge’s authorization policies [2].