Skip to content

Auth Flows — React + API

Key Points

  • BFF (Backend-for-Frontend) pattern is the modern best practice. SPA never holds tokens; backend uses cookie session; backend talks to APIs with bearer.
  • Direct OIDC + PKCE in SPA still works but exposes tokens to JS. Use HttpOnly storage and short lifetimes.
  • Token storage trade-offs: HttpOnly cookie (best) > in-memory (good UX cost) > sessionStorage (XSS risk) > localStorage (worst).
  • Silent refresh via refresh tokens (with rotation) or iframe-based silent renew.
  • See JWT Validation Pitfalls, OAuth2 & OIDC Flows.

Concepts (deep dive)

BFF pattern

[Browser] ←HttpOnly cookie session→ [BFF (.NET)] ←bearer→ [API]
                                       ↓ holds tokens
                                       ↓ /api/* proxy

User logs in via OIDC at BFFBFF establishes cookie session → SPA sends cookie automatically → BFF uses stored access token to call API.

Benefits: - Tokens never reach the browser. - XSS can't exfiltrate tokens. - BFF has fixed origin → CORS minimal. - BFF can attach scopes / refresh transparently.

Implementation: BFF endpoints /api/* proxy with auth attached. Use YARP or custom proxy.

Library: Duende.BFF / Microsoft.Identity.Web

builder.Services.AddBff()
    .AddRemoteApis();

builder.Services.AddAuthentication("cookie")
    .AddCookie("cookie")
    .AddOpenIdConnect("oidc", o =>
    {
        o.Authority = "...";
        o.ClientId = "...";
        o.ClientSecret = "...";
        o.ResponseType = "code";
        o.UsePkce = true;
        o.SaveTokens = true;
        o.Scope.Add("api");
    });

app.UseAuthentication();
app.UseBff();
app.MapBffManagementEndpoints();   // /bff/login, /bff/logout, /bff/user
app.MapRemoteBffApiEndpoint("/api/orders", "https://orders-api/")
   .RequireAccessToken();

SPA calls /bff/login to start OIDC; cookie set; SPA calls /api/* with cookie; BFF forwards with bearer.

Direct OIDC + PKCE in React (alternative)

import { UserManager } from 'oidc-client-ts';

const mgr = new UserManager({
    authority: 'https://idp.contoso.com',
    client_id: 'spa',
    redirect_uri: 'https://app/callback',
    response_type: 'code',
    scope: 'openid profile api',
    automaticSilentRenew: true,
    userStore: new WebStorageStateStore({ store: window.sessionStorage })
});
function App() {
    const [user, setUser] = useState<User | null>(null);
    useEffect(() => { mgr.getUser().then(setUser); }, []);
    if (!user) return <button onClick={() => mgr.signinRedirect()}>Login</button>;
    return <Authed user={user} />;
}

Risks: - Token visible to JSXSS bad. - Refresh token in browser is sensitive (use rotation).

Token storage

Storage XSS-safe Persistence Notes
HttpOnly cookie Yes Persistent Best
In-memory (JS var) Mostly Lost on refresh Annoying UX
sessionStorage No Tab-local Avoid
localStorage No Persistent Worst

For 2026 senior practice: BFF + HttpOnly cookies.

CSRF mitigation

With cookie auth, CSRF is a concern. Mitigate via: - SameSite=Lax/Strict cookies. - Anti-forgery token for state-changing requests. BFF helpers usually set this up. - Custom header (e.g., X-Requested-With: XMLHttpRequest) — partial mitigation.

Silent refresh (direct SPA)

mgr.events.addAccessTokenExpiring(async () => {
    try { await mgr.signinSilent(); }
    catch { mgr.signinRedirect(); }
});

Silent renew via iframe / refresh token. With refresh-token rotation: each renew issues a new refresh token; old invalidated.

Logout

BFF:

SPA → /bff/logout → BFF clears cookie → IdP logout → redirect back

Direct SPA:

await mgr.signoutRedirect();

Calling API with token

// BFF
const r = await fetch('/api/orders', { credentials: 'include' });   // cookie auto

// Direct
const user = await mgr.getUser();
const r = await fetch('/api/orders', {
    headers: { Authorization: `Bearer ${user.access_token}` }
});

Tanstack Query + auth

const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            retry: (count, error) => error.status !== 401 && count < 3
        }
    }
});

useQuery({
    queryKey: ['orders'],
    queryFn: async () => {
        const r = await fetch('/api/orders', { credentials: 'include' });
        if (r.status === 401) {
            window.location.href = '/bff/login';
            return [];
        }
        return r.json();
    }
});

Multi-tenant scenarios

  • Tenant ID in URL path or subdomain.
  • Per-tenant authority (B2B federation).
  • Or single IdP with tenant claim.

Mobile + same backend

For React Native, OAuth2 PKCE in-app browser. Token in secure storage (Keychain / Keystore). Same API backend works.

Common pitfalls

  • Token in localStorageXSS.
  • No refresh strategyUX death by re-login.
  • Different origins for BFF and SPA — defeats the pattern.
  • No CSRF protection with cookie auth.

Code: correct vs wrong

❌ Wrong: token in localStorage

localStorage.setItem('accessToken', token);
fetch('/api/foo', { credentials: 'include' });   // cookie attached

❌ Wrong: long-lived access tokens

exp: 24h → stolen token usable for a day

✅ Correct: short access + refresh rotation

exp: 15min, refresh rotated on use, cookie HttpOnly

Design patterns for this topic

Pattern 1 — "BFF for browsers"

  • Intent: SPA never sees tokens.

Pattern 2 — "Refresh token rotation"

  • Intent: detect theft.

Pattern 3 — "SameSite=Lax + anti-forgery for state-changing ops"

  • Intent: CSRF defense.

Pattern 4 — "Short access token lifetimes"

  • Intent: smaller theft window.

Pattern 5 — "OpenAPI-typed API client + auth in fetcher"

  • Intent: consistency.

Pros & cons / trade-offs

Pattern Pros Cons
BFF Most secure Extra component
Direct OIDC No BFF Token-in-browser risks
In-memory token XSS-safer Lost on refresh
Refresh rotation Theft detection Complexity

When to use / when to avoid

  • Use BFF for new SPAs handling sensitive data.
  • Use direct OIDC + PKCE for simple cases / no backend control.
  • Avoid localStorage for tokens.
  • Avoid long access tokens.

Interview Q&A

Q1. Why BFF? SPA never holds tokens; XSS can't steal. Cookie-based session to backend; backend has token.

Q2. Token storage worst-to-best? localStorage < sessionStorage < in-memory < HttpOnly cookie.

Q3. CSRF with cookies? SameSite=Lax/Strict + anti-forgery token for state-changing.

Q4. Refresh token rotation? Each refresh issues a new refresh token; old invalidated. Detect theft.

Q5. PKCE for SPAs? Mitigates code interception. Required for public clients.

Q6. Silent renew? Iframe or refresh token. Without UX disruption.

Q7. BFF library options? Duende.BFF (commercial), Microsoft.Identity.Web (free), hand-rolled with YARP.

Q8. Cookie + Bearer mixing? BFF: cookie SPABFF, bearer BFFAPI. Common.

Q9. Logout in BFF? SPA → /bff/logout → cookie cleared + IdP logout.

Q10. Multi-tenant? Tenant in URL, subdomain, or claim. Per-tenant authority for B2B federation.

Q11. Tanstack Query 401 handling? Retry policy with redirect to login on 401.

Q12. Mobile + same backend? PKCE in-app browser; token in Keychain/Keystore. Same API.


Gotchas / common mistakes

  • ⚠️ Tokens in localStorage.
  • ⚠️ Long access tokens.
  • ⚠️ No refresh strategy — re-login UX.
  • ⚠️ Cross-origin cookies without SameSite=None+Secure.
  • ⚠️ No anti-forgery for state-changing.

Further reading