SPA Authentication: A Complete Guide (2026)
Note: This guide applies to all OAuth 2.0/OIDC-compliant authentication servers, not just AuthHero. The architectural decisions and trade-offs discussed here are universal concerns for modern single-page applications.
Introduction
Authentication in Single-Page Applications has become increasingly complex due to privacy initiatives from browser vendors. In 2026, there's no single "best practice"—instead, you must choose between competing priorities: security, user experience, and cross-domain functionality.
This guide will help you understand the fundamental trade-offs and choose the right architecture for your application.
Part 1: Choosing Your Architecture (The Privacy vs. SSO Trade-off)
The "best" way to implement authentication depends on a single critical question: Does your session need to live on one domain, or across many?
1. The BFF (Backend-for-Frontend) ⭐ Recommended
The gold standard for security. A server-side proxy handles the login and stores tokens in a secure, server-side session. The SPA only sees a first-party, Secure, HttpOnly cookie.
Cross-Subdomain Support
While the BFF cookie can't cross different top-level domains (app.com → partner.org), it works across subdomains on the same root domain (e.g., app.example.com, admin.example.com) by setting Domain=.example.com. On Safari, this requires all subdomains to resolve within the same /16 IP range—see Section 7: Strategic Auth Setup for Safari for details.
Vite Proxy Example
During development, you can proxy API and auth paths through Vite so everything runs on a single origin—no CORS, no third-party cookie issues:
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
// Proxy auth endpoints to your BFF / auth server
"/api/auth": {
target: "http://localhost:3001",
changeOrigin: true,
},
// Proxy API calls
"/api": {
target: "http://localhost:3001",
changeOrigin: true,
},
},
},
});In production, use a reverse proxy (Nginx, Cloudflare, etc.) to achieve the same single-origin setup. This ensures cookies are always first-party and avoids all ITP restrictions.
Pros
- Immune to token theft via XSS attacks
- No third-party cookie issues
- Tokens never exposed to JavaScript
- Works perfectly with modern browser privacy features
Cons
- Implementation complexity
- Requires a server component
- No cross-domain SSO (different top-level domains)
- Requires reverse proxy configuration in production
When to Use
- High-security applications (banking, healthcare, etc.)
- Single-domain applications
- When you already have a backend infrastructure
- When XSS risk is unacceptable
2. Token Handler Pattern (BFF Without the BFF)
A hybrid approach where your API service handles the OAuth flow and stores tokens in HttpOnly cookies—giving you BFF-level security without a separate BFF service.
Safari caveat: Because this pattern relies on cross-subdomain cookies (
Domain=.example.com), it is subject to Safari ITP restrictions. All subdomains must share the same/16IP range (or the cookie is capped to 7 days), and the user must interact with the domain at least once every 30 days (or Safari purges the cookie entirely). See Section 7: Strategic Auth Setup for Safari for the full requirements.
This pattern (documented by Curity as the Token Handler Pattern) works when your SPA, API, and auth server share the same top-level domain.
How It Works
┌─────────────────────────────────────────────────────────────┐
│ .example.com (top domain) │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ SPA │ │ API │ │ Auth │ │
│ │ app.example │────│ api.example │────│ auth.example│ │
│ │ .com │ │ .com │ │ .com │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │
│ │ ┌─────────────┴─────────────┐ │
│ │ │ API acts as OAuth client │ │
│ │ │ Stores tokens in cookies │ │
│ │ └───────────────────────────┘ │
│ │ │
│ └──── SPA never sees tokens ────────────────────────│
│ │
└─────────────────────────────────────────────────────────────┘- SPA initiates login → API redirects to auth server
- Auth server authenticates → redirects back to API with code
- API exchanges code for tokens → stores in HttpOnly cookies
- API redirects to SPA → cookies are set
- SPA makes API calls → cookies sent automatically
- API uses refresh token to maintain session
Cookie Architecture
The key insight is using two cookies with different scopes:
# Refresh token - scoped to API only (maximum protection)
Set-Cookie: __Host-rt=<JWE>; Path=/; Secure; HttpOnly; SameSite=Strict
# Access token - scoped to top domain (shared across subdomains)
Set-Cookie: at=<JWE>; Domain=.example.com; Path=/; Secure; HttpOnly; SameSite=LaxWhy JWE (encrypted JWT)?
- Prevents content inspection by proxies or browser extensions
- Allows other services on the same domain to decrypt and validate
- Tokens remain opaque to JavaScript even if somehow exposed
Why different scopes?
- Refresh token isolated to API: Only the API can refresh the session
- Access token domain-wide: Other services (e.g.,
cdn.example.com,ws.example.com) can validate requests
Shared Key for Access Token Decryption
For other services to validate the access token cookie:
// All services on .example.com share this key
const JWE_SHARED_KEY = process.env.ACCESS_TOKEN_JWE_KEY;
// Any service can decrypt and validate
async function validateRequest(req) {
const encryptedToken = req.cookies.at;
const accessToken = await decryptJWE(encryptedToken, JWE_SHARED_KEY);
return validateJWT(accessToken);
}Key distribution options:
- Environment variables from your secrets manager
- Derived from a shared secret using HKDF
- Fetched from a central key service
Session Synchronization
The API uses the refresh token to keep the session in sync with the auth server:
// API middleware
async function ensureValidSession(req, res, next) {
const accessToken = decryptJWE(req.cookies.at, JWE_KEY);
if (isExpired(accessToken)) {
try {
// Refresh using the isolated refresh token
const refreshToken = decryptJWE(req.cookies['__Host-rt'], JWE_KEY);
const { access_token, refresh_token } = await tokenEndpoint.refresh(refreshToken);
// Update cookies with new tokens
setAccessTokenCookie(res, access_token);
setRefreshTokenCookie(res, refresh_token); // Rotation
req.accessToken = access_token;
} catch (error) {
// Refresh failed - session ended at auth server
clearAuthCookies(res);
return res.status(401).json({ error: 'session_expired' });
}
} else {
req.accessToken = accessToken;
}
next();
}Important Considerations
Cookie Size Limits: JWE tokens are larger than plain JWTs. With a ~4KB cookie limit:
- Keep access token claims minimal
- Fetch full user profile via API call if needed
- Consider the "Phantom Token" pattern (opaque reference in cookie, real token stays server-side)
CSRF Protection: Even with SameSite cookies, consider additional CSRF protection for state-changing operations:
// Double-submit cookie pattern
res.cookie('csrf', csrfToken, { sameSite: 'Strict' });
res.setHeader('X-CSRF-Token', csrfToken);
// Verify on mutation requests
if (req.cookies.csrf !== req.headers['x-csrf-token']) {
return res.status(403).json({ error: 'csrf_mismatch' });
}Key Rotation: Plan for JWE key rotation:
- Support decrypting with both old and new keys during transition
- Re-encrypt cookies on next request with new key
Pros
- BFF-level security without a separate BFF service
- Tokens never exposed to JavaScript
- Works across subdomains on the same top-level domain
- API handles token lifecycle—simpler architecture
- Refresh token rotation maintains security
Cons
- Requires same top-level domain for SPA, API, and auth
- No cross-domain SSO (different top-level domains)
- Cookie size limits require careful token design
- Key distribution adds operational complexity
- Requires CORS configuration between subdomains
When to Use
- You want BFF security without BFF infrastructure
- Your SPA, API, and auth share a top-level domain
- You have multiple services that need to validate tokens
- You want cross-subdomain authentication
3. Refresh Tokens (The Modern SPA Standard)
The SPA receives an Access Token and a Refresh Token. The access token is short-lived (minutes to hours), while the refresh token can last days or weeks.
The Auth0-SPA-JS Advantage
If you use libraries like auth0-spa-js or similar OIDC client libraries, this flow is incredibly easy to enable:
const auth0 = new Auth0Client({
domain: "your-auth-server.com",
clientId: "your-client-id",
useRefreshTokens: true,
cacheLocation: "localstorage", // or 'memory'
});The library automatically handles the "background refresh" using the Refresh Token via a direct POST request to the token endpoint, bypassing the need for iframes entirely.
The Cross-Domain Downside
Like the BFF, localStorage is scoped to a single origin. A Refresh Token on site-a.com is invisible to site-b.com.
Pros
- No iframes required
- Works even when third-party cookies are blocked
- Perfect for mobile browsers and privacy-focused browsers
- Native mobile app equivalent flow
- Relatively simple to implement with modern libraries
Cons
- XSS Vulnerability: If a script can read your
localStorage, it can steal your Refresh Token - Requires Refresh Token Rotation for security (each use issues a new token and invalidates the old one)
- No cross-domain SSO
- Token storage decisions (localStorage vs. memory) affect user experience
When to Use
- Modern single-domain SPAs
- Mobile-responsive applications
- When third-party cookie support is uncertain
- When you can implement proper XSS protection
Critical Security Requirement: Refresh Token Rotation
Always enable Refresh Token Rotation. This ensures that:
- Each refresh operation issues a new refresh token
- The old refresh token is immediately invalidated
- Concurrent refresh attempts trigger security alerts
- Token theft has a limited window of exploitation
4. Silent Auth (The "Classic" Iframe)
The SPA opens a hidden iframe pointing to the auth server. Since the user has a session cookie on the auth domain (e.g., login.provider.com), the server recognizes them and passes a new token back to the app.
The Cross-Domain Superpower
This is the only way to achieve true "logged in one, logged in all" SSO. Because the session lives on the Auth Domain (not your app's domain), every app that points an iframe to that domain can "see" the session.
The 2026 Reality: Browser Privacy Impacts Silent Auth
This method is affected by browser privacy initiatives, though the landscape has evolved:
- Chrome: Still supports third-party cookies (Google abandoned full deprecation in July 2024, shifting to a user-choice model reaffirmed in October 2025). CHIPS (Partitioned Cookies) provides a reliable solution for iframe-based auth.
- Safari ITP: Kills iframe-based auth after 30 days of inactivity on the auth domain. Safari 18.4 briefly added CHIPS support, but WebKit subsequently disabled it due to incomplete handling.
- Firefox Enhanced Tracking Protection: Blocks known authentication domains
- Brave: Aggressive blocking by default
- Android (Chrome/WebView): Third-party cookies still work, but standard cookies are frequently wiped during browser or system updates—causing unexpected logouts. CHIPS cookies survive these updates, making them essential for reliable session persistence on Android.
Pros
- True cross-domain SSO
- Seamless token renewal without user interaction
- No navigation disruption
Cons
- Browser support varies significantly
- CHIPS works well on Chrome/Android but Safari actively disabled it after initial 18.4 support
- Safari's 30-day timer requires regular user interaction with the auth domain
- May silently fail, requiring fallback mechanisms
Practical Solution: Use CHIPS for Chrome/Android devices combined with
prompt=noneredirect fallback for Safari/iOS. This combination provides reliable cross-subdomain authentication across all platforms.
When to Use (if at all)
- Enterprise environments with managed browsers
- Temporary solution while migrating to Refresh Tokens
- As a fallback with proper error handling
- When you control both the auth domain and can ensure user interaction
Part 2: The Battle of UX — Popups vs. Redirects
Once you've picked your architecture, you need to decide how the user actually logs in. This is where most developers get "browser-shamed."
The Redirect Flow (loginWithRedirect)
The user is sent away from your app to the login page and returns after authentication.
// Example with auth0-spa-js
await auth0.loginWithRedirect({
appState: {
targetUrl: window.location.pathname,
},
});
// After redirect back
const { appState } = await auth0.handleRedirectCallback();
window.location.href = appState?.targetUrl || "/";Best For
- Subdomains (e.g.,
app.site.comtologin.site.com) - Primary login flow
- Mobile devices
Pros
- Extremely reliable — works everywhere
- Resets Safari's ITP 30-day timer on the auth domain
- Works on all devices and browsers
- No popup blocker issues
- Can handle complex authentication flows (MFA, password reset, etc.)
Cons
- Destroys application state (unless carefully preserved)
- The "white flash" of navigation disrupts UX
- Slower perceived performance
- Requires state management for deep links
The Popup Flow (loginWithPopup)
A small window opens for the login and closes upon completion.
// Example with auth0-spa-js
await auth0.loginWithPopup({
// options
});
// User is now authenticated, no redirect neededBest For
- True cross-domain scenarios (e.g.,
mysite.setoauth-provider.no) - Secondary authentication actions (adding another account)
- Desktop applications
Pros
- Preserves application state completely
- Feels "snappier" on desktop
- No navigation disruption
- Better for multi-step flows within your app
Cons
- Blocked by popup blockers (especially on mobile)
- Fragile connections — often lose
window.openerlink after 60 seconds - Terrible UX on mobile devices
- May silently fail with no clear error to users
- Users may close the popup accidentally
Part 3: Edge Cases Libraries Don't Solve
Even excellent libraries like auth0-spa-js handle the core OAuth/OIDC flows beautifully, but they cannot solve browser-specific quirks and edge cases for you. Here are the critical issues you must handle yourself:
1. Not Hitting Silent Auth All the Time
Problem: If your app calls getTokenSilently() on every page load or navigation, you'll hammer the auth server with iframe requests.
Solution: Maintain a first-party cookie or session storage flag with the token expiration time:
// Set a first-party cookie when you get a token
function setTokenExpiryMarker(expiresIn) {
const expiryTime = Date.now() + expiresIn * 1000;
document.cookie = `token_valid_until=${expiryTime}; path=/; SameSite=Lax; Secure`;
}
// Check before calling getTokenSilently
async function getToken() {
const tokenValidUntil = getCookie("token_valid_until");
if (tokenValidUntil && Date.now() < parseInt(tokenValidUntil)) {
// Token should still be valid in memory cache
return await auth0.getTokenSilently({ cacheMode: "cache-only" });
}
// Need to refresh
const token = await auth0.getTokenSilently();
setTokenExpiryMarker(3600); // 1 hour
return token;
}2. Safari's 30-Day ITP Wall
Problem: Safari's Intelligent Tracking Prevention (ITP) deletes third-party cookies and even localStorage for domains you haven't interacted with in 30 days. This kills silent authentication.
Solution Strategies:
A. Force Redirect on Silent Auth Failure
async function authenticate() {
try {
await auth0.getTokenSilently();
} catch (error) {
if (error.error === "login_required") {
// Silent auth failed, probably ITP
// Force a redirect to reset the timer
// This will navigate away - errors handled in callback
await auth0.loginWithRedirect({
authorizationParams: {
prompt: "none", // Try to skip login screen if possible
}
});
}
}
}B. Warn Users Before the 30-Day Deadline
// Store last successful auth timestamp
function recordAuthInteraction() {
localStorage.setItem("last_auth_interaction", Date.now().toString());
}
// Check if approaching the deadline
function checkITPDeadline() {
const lastInteraction = localStorage.getItem("last_auth_interaction");
if (lastInteraction) {
const daysSinceAuth =
(Date.now() - parseInt(lastInteraction)) / (1000 * 60 * 60 * 24);
if (daysSinceAuth > 25) {
// Show warning: "You'll need to log in again soon"
showReauthenticationWarning();
}
}
}3. CHIPS (Partitioned Cookies) for Cross-Subdomain Auth
Background: While Google abandoned full third-party cookie deprecation in 2024 (shifting to user choice), CHIPS provides a reliable solution for cross-subdomain authentication, particularly on Android devices where cookie persistence has historically been problematic.
The Android Cookie Persistence Problem
On Android, third-party cookies technically work—silent auth via iframes functions correctly. However, Android's cookie storage behaves differently from desktop browsers:
- Browser/WebView updates trigger cookie jar cleanups that wipe standard third-party cookies
- System updates can also clear non-essential cookie storage
- App updates (for apps using WebView) often reset the cookie state
The result: users are unexpectedly logged out after updates, even though they were "remembered" before. This is particularly frustrating on Android where Chrome and WebView updates happen frequently in the background.
CHIPS cookies are treated differently. Because they're explicitly partitioned and marked for cross-site use, they survive these cleanup operations. This makes CHIPS essential for reliable Android authentication—not because standard cookies are blocked, but because they don't persist.
Solution: This requires server-side changes to your auth server:
Set-Cookie: session=abc123; SameSite=None; Secure; PartitionedCHIPS cookies are partitioned per top-level site, which means:
- A CHIPS cookie set from
login.auth.comwhile onapp-a.comis separate from - A CHIPS cookie set from
login.auth.comwhile onapp-b.com
This kills true cross-domain SSO (different domains). However, cross-subdomain SSO works (e.g., app.company.com and admin.company.com sharing auth.company.com).
Browser Support:
- ✅ Chrome/Android: Full support, solves cookie persistence issues on Android
- ❌ Safari/iOS: Safari 18.4 briefly added support, but WebKit subsequently disabled it
- ✅ Firefox: Supported
Recommended Approach: Use CHIPS as the primary method with a prompt=none redirect fallback:
// Step 1: Try silent auth first (works with CHIPS on Chrome/Android)
async function ensureAuthenticated() {
try {
return await auth0.getTokenSilently();
} catch (error) {
if (error.error === 'login_required') {
// Silent auth failed - initiate prompt=none redirect
// Note: This navigates away, so code after this won't execute
await auth0.loginWithRedirect({
authorizationParams: {
prompt: 'none'
}
});
}
throw error;
}
}
// Step 2: Handle the redirect callback (on your callback page or app init)
async function handleAuthCallback() {
// Check if this is a redirect callback
if (window.location.search.includes('code=') ||
window.location.search.includes('error=')) {
try {
await auth0.handleRedirectCallback();
// Success! User is now authenticated
} catch (error) {
// prompt=none failures arrive here as errors
// Common errors: 'login_required', 'consent_required', 'interaction_required'
if (error.error === 'login_required' ||
error.error === 'interaction_required') {
// No existing session on auth server - need interactive login
await auth0.loginWithRedirect();
}
}
}
}Important:
loginWithRedirectcauses a full page navigation. Errors fromprompt=none(likelogin_required) are returned as URL parameters after the redirect and must be handled inhandleRedirectCallback(), not in a try-catch around the redirect call.
This combination provides reliable cross-subdomain authentication across all platforms.
4. iOS Safari Back Button Freeze
Problem: On iOS Safari, if you navigate away from an SPA (using the browser back button) and then navigate forward again, JavaScript may not execute properly. Pending promises from silent auth can hang forever.
Solution: Implement timeouts and reauth on visibility change:
async function getTokenWithTimeout(timeoutMs = 10000) {
return Promise.race([
auth0.getTokenSilently(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Token fetch timeout")), timeoutMs),
),
]);
}
// Re-check authentication when page becomes visible
document.addEventListener("visibilitychange", async () => {
if (!document.hidden) {
try {
await getTokenWithTimeout(5000);
} catch (error) {
// Timeout or error - might need to redirect
console.warn("Auth check failed on visibility change", error);
}
}
});5. The "Ghost" Query String (Link Tracking Protection)
Problem: Some privacy features (like iOS Mail Link Tracking Protection) strip query parameters before the page loads. If your authentication callback relies on ?code=... or ?state=... in the URL, it may disappear before your JavaScript runs.
Solution: Use hash fragments instead of query parameters for your callback:
// Configure your auth client to use hash-based responses
const auth0 = new Auth0Client({
domain: "your-auth-server.com",
clientId: "your-client-id",
authorizationParams: {
response_mode: "fragment", // Use #code=... instead of ?code=...
},
});Hash fragments (#) are never sent to the server and are more resistant to stripping by privacy features.
6. Handling Storage Access Blocking
Problem: Some browsers (Safari, Firefox with privacy mode) may block access to localStorage or sessionStorage in certain contexts (iframes, private browsing).
Solution: Implement fallback storage mechanisms:
class StorageManager {
constructor() {
this.useStorage = this.detectStorageAvailability();
this.memoryCache = new Map();
}
detectStorageAvailability() {
try {
const test = "__storage_test__";
localStorage.setItem(test, test);
localStorage.removeItem(test);
return true;
} catch (e) {
return false;
}
}
set(key, value) {
if (this.useStorage) {
localStorage.setItem(key, value);
} else {
this.memoryCache.set(key, value);
}
}
get(key) {
if (this.useStorage) {
return localStorage.getItem(key);
} else {
return this.memoryCache.get(key);
}
}
}
const storage = new StorageManager();7. Strategic Auth Setup for Safari (ITP Compliance)
To achieve a persistent 30-day session on modern WebKit engines (Safari 26+), you must navigate Intelligent Tracking Prevention (ITP) restrictions. Standard client-side token management is no longer viable for long-term sessions.
A. The Core Architecture: BFF Pattern
Avoid handling OAuth/OIDC tokens (access_token, id_token, refresh_token) in the browser's localStorage or sessionStorage. Instead, implement a Backend-for-Frontend (BFF) (see Part 1 for the full pattern).
- Server-Side Exchange: The browser never sees the
?code=orclient_secret. The exchange happens server-to-server. - The Session Cookie: The BFF issues a traditional, encrypted session cookie to the browser.
- Why: Safari limits cookies set via JavaScript (
document.cookie) to a maximum of 7 days. Cookies set via theSet-CookieHTTP header can persist for the full duration (up to the 400-day WebKit cap).
B. Navigating the IP-Address Constraint
Safari 26 employs strict "CNAME Cloaking" defense. If the subdomain setting the cookie and the subdomain the user is visiting have different IP addresses, Safari may cap the cookie to 7 days even if it is an HTTP-set cookie.
- The /16 Rule: For IPv4, the first two octets (the
/16subnet, e.g.,1.2.x.x) must match between the subdomains. - The Solution: Use a Unified Edge/Proxy. Route all traffic (e.g.,
news.example.com,auth.example.com, andsport.example.com) through a single Load Balancer or CDN (Cloudflare, AWS CloudFront, Akamai). This ensures all subdomains present an IP within the same/16range to the client.
C. Cross-Subdomain Session Sharing
To allow a single login to persist across multiple subdomains (e.g., a.example.com and b.example.com):
- Domain Attribute: Set the cookie on the root domain:
Set-Cookie: sid=xyz; Domain=example.com; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=2592000- The "Bounce Tracking" Trap: Avoid redirecting users through a dedicated "tracking" or "auth-only" domain that they never interact with. Safari may flag this as a "bouncer" and purge its storage. Keeping the auth-flow on the primary brand domain is essential.
D. Preventing "Link Decoration" Penalties
If a user arrives at your site via a link containing query parameters (like ?code= or ?click_id=) from a site Safari deems a "tracker," all cookies set in that session are capped at 24 hours.
Implementation Strategy:
- BFF receives the callback:
example.com/api/auth/callback?code=123. - BFF validates and sets the
Set-Cookieheader. - Crucial: BFF performs a 302 Redirect to a "Clean URL" (e.g.,
example.com/dashboard) without any query parameters. - This "clears" the link decoration context in the eyes of Safari.
Safari ITP Technical Checklist
| Feature | Requirement | Reason |
|---|---|---|
| Storage | Set-Cookie (Server-side) | Bypasses 7-day JS-cookie limit. |
| Security | HttpOnly, Secure | Required for long-term persistence and XSS protection. |
| Policy | SameSite=Lax | Ensures cookies are sent during top-level navigations. |
| Network | Shared IP Range (/16) | Prevents ITP "CNAME Cloaking" restrictions. |
| UX | Immediate Redirect to Clean URL | Prevents 24-hour "Link Decoration" cap. |
Bottom line: To keep a session for 30 days on Safari, you must act as a true first-party. By using a BFF on a shared IP infrastructure and cleaning the URL immediately after login, you satisfy Safari's heuristics for a legitimate, long-term user session.
Part 4: Recommended Implementations by Use Case
Single-Domain SPA (e.g., app.company.com)
Recommended Architecture: Refresh Tokens
Flow: Redirect for login, Refresh Tokens for renewal
const auth0 = new Auth0Client({
domain: "auth.company.com",
clientId: "spa-client-id",
useRefreshTokens: true,
cacheLocation: "localstorage",
authorizationParams: {
response_mode: "fragment",
scope: "openid profile email offline_access",
},
});
// Login
await auth0.loginWithRedirect();
// On callback page
await auth0.handleRedirectCallback();
// Get token (uses refresh token automatically when needed)
const token = await auth0.getTokenSilently();Multi-Domain SSO (e.g., app-a.com and app-b.com)
Bad News: True cross-domain SSO is dying in 2026.
Best Compromise: Refresh Tokens + UX Optimization
- Use Refresh Tokens on each domain independently
- Optimize the login flow with
prompt=noneto skip re-entry of credentials - Set long-lived refresh tokens (30+ days)
- Consider federated identity (social logins) to reduce friction
// On each domain, try silent login first via redirect
// Step 1: Initiate the prompt=none redirect
await auth0.loginWithRedirect({
authorizationParams: {
prompt: "none", // Skip login UI if session exists on auth server
},
});
// Step 2: Handle the callback (this runs after redirect returns)
async function handleCallback() {
try {
await auth0.handleRedirectCallback();
// Success - user had an existing session
} catch (error) {
// prompt=none failed - no existing session, need interactive login
if (error.error === 'login_required' ||
error.error === 'interaction_required') {
await auth0.loginWithRedirect();
}
}
}Note: Errors from
prompt=noneare returned via URL parameters after the redirect completes. Handle them in your callback handler, not with try-catch aroundloginWithRedirect.
High-Security Applications
Recommended Architecture: BFF (Backend-for-Frontend)
Benefits:
- Zero token exposure to JavaScript
- Can implement sophisticated security policies server-side
- Immune to XSS-based token theft
Trade-offs:
- More complex architecture
- Higher operational costs
- No cross-domain support
Part 5: Testing Your Implementation
Critical Tests to Run
Popup Blocker Test:
- Try
loginWithPopup()after a delayed action (not direct user click) - Verify graceful fallback to redirect
- Try
iOS Back Button Test:
- Navigate away from your SPA
- Use iOS Safari back button to return
- Verify app re-initializes correctly
30-Day ITP Simulation:
- Clear Safari cookies
- Wait 30+ days (or manually delete ITP state)
- Verify fallback to redirect login
Token Expiry During Inactivity:
- Leave app open for longer than access token lifetime
- Return and interact
- Verify seamless refresh
Network Interruption During Auth:
- Start login flow
- Disable network mid-flow
- Verify error handling and recovery
Cross-Tab Authentication:
- Open app in two tabs
- Log in via one tab
- Verify other tab detects authentication
Conclusion
In 2026, SPA authentication is a game of trade-offs:
- Security vs. Convenience: BFF is most secure but requires server infrastructure
- Cross-Domain vs. Privacy: True SSO is dying; Refresh Tokens are the future
- UX vs. Reliability: Redirects are rock-solid; popups are fragile but smoother
Our recommendations for modern SPAs:
| Scenario | Recommended Approach |
|---|---|
| Same top-level domain (SPA + API + Auth) | Token Handler Pattern — BFF security without BFF complexity |
| Single-domain, client-side simplicity | Refresh Tokens — Modern standard with proper rotation |
| Maximum security, have backend infra | Full BFF — Gold standard, tokens never touch the browser |
| Cross-domain SSO required | Silent Auth + Fallbacks — But expect browser friction |
The Token Handler Pattern is particularly compelling in 2026: it gives you the security benefits of a BFF (tokens never in JavaScript) while leveraging your existing API infrastructure. If your architecture allows same-domain deployment, it's often the sweet spot.
For simpler setups or when you can't control domain architecture, Refresh Tokens with Redirect Flow remains excellent:
- ✅ Security (with proper rotation)
- ✅ Privacy compliance
- ✅ Reliability across all browsers and devices
- ❌ Cross-domain SSO (but that's dying anyway)
Whatever you choose, remember: Libraries handle the protocol, but you must handle the browsers.