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
User logs in via OIDC at BFF → BFF 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 JS → XSS 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:
Direct SPA:
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 localStorage — XSS.
- No refresh strategy — UX 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
✅ Correct: BFF cookie
❌ Wrong: long-lived access tokens
✅ Correct: short access + refresh rotation
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 SPA→BFF, bearer BFF→API. 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.