SPA Authentication β JWT, OAuth, and Session Management in SPAs
In this tutorial, you will learn about SPA Authentication. We cover key concepts, practical examples, and best practices to help you master this topic.
SPA authentication covers JWT tokens, OAuth2 authorization code flow with PKCE, refresh token rotation, session persistence, and secure login patterns for single-page applications.
What You'll Learn
By the end of this tutorial, you will understand how to implement authentication in SPAs using JWT tokens with refresh rotation, OAuth2 with PKCE for third-party login, secure token storage in HttpOnly cookies, and session persistence across page refreshes.
Why It Matters
Authentication is the most security-critical feature in most SPAs. A flawed auth implementation exposes user data, allows account takeover, and can lead to legal liability. Proper SPA authentication balances security with user experience β users expect to stay logged in without repeated logins.
Real-World Use
A project management SPA implemented OAuth2 with PKCE for Google login and JWT with refresh token rotation. Before: users logged in every 15 minutes due to short-lived tokens. After: seamless sessions with automatic silent refresh, zero token theft incidents in 6 months.
SPA Authentication Flow
βββββββββββ ββββββββββββ ββββββββββββ
β Browserβ β SPA β β Auth β
β (User) β β Client β β Server β
ββββββ¬βββββ ββββββ¬ββββββ ββββββ¬ββββββ
β β β
β 1. Login request β β
βββββββββββββββββββ>β β
β β 2. Send creds β
β ββββββββββββββββββββ>β
β β β
β β 3. Return tokens β
β β (access + refresh)β
β β<ββββββββββββββββββββ
β β β
β 4. Store refresh β β
β in HttpOnly β β
β cookie β β
β β β
β 5. API call with β β
β access token β β
βββββββββββββββββββ>β β
β β 6. Validate token β
β ββββββββββββββββββββ>β
β β β
β 7. Token expired β β
β (401 response) β β
β<βββββββββββββββββββ β
β β β
β 8. Silent refreshβ β
β via cookie β β
β ββββββββββββββββββββ>β
β β 9. New access β
β β token β
β β<ββββββββββββββββββββ
β 10. Retry API β β
β with new token β β
βββββββββββββββββββββ ββββββββββββ
Think of JWT tokens like a hotel key card. The access token is your room key β it works for a short time (15-30 minutes) and grants access to your room. The refresh token is like your ID at the front desk β when your key stops working, you show your ID to get a new key. The server checks if your ID is still valid and issues a new key.
JWT Authentication with Refresh Token Rotation
// Auth service with refresh token rotation
class AuthService {
constructor() {
this.accessToken = null;
this.refreshAttempts = 0;
this.maxRefreshAttempts = 3;
}
async login(email, password) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
credentials: 'include' // refresh token in HttpOnly cookie
});
if (!response.ok) {
throw new Error('Login failed');
}
const data = await response.json();
this.accessToken = data.accessToken;
this.refreshAttempts = 0;
return data.user;
}
async refreshToken() {
if (this.refreshAttempts >= this.maxRefreshAttempts) {
await this.logout();
throw new Error('Max refresh attempts exceeded');
}
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include' // sends HttpOnly refresh cookie
});
if (!response.ok) {
this.refreshAttempts++;
throw new Error('Token refresh failed');
}
const data = await response.json();
this.accessToken = data.accessToken;
this.refreshAttempts = 0;
return this.accessToken;
} catch (error) {
if (this.refreshAttempts >= this.maxRefreshAttempts) {
await this.logout();
}
throw error;
}
}
async logout() {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include'
});
this.accessToken = null;
this.refreshAttempts = 0;
}
getAccessToken() {
return this.accessToken;
}
}
// Usage with automatic token refresh
const authService = new AuthService();
async function authenticatedFetch(url, options = {}) {
const token = authService.getAccessToken();
const response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`
}
});
if (response.status === 401) {
// Token expired β try refresh
try {
await authService.refreshToken();
const newToken = authService.getAccessToken();
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${newToken}`
}
});
} catch {
// Redirect to login
window.location.href = '/login';
throw new Error('Session expired');
}
}
return response;
}
OAuth2 with PKCE
// OAuth2 Authorization Code flow with PKCE
class OAuthService {
constructor({ clientId, redirectUri, authorizationUrl, tokenUrl }) {
this.clientId = clientId;
this.redirectUri = redirectUri;
this.authorizationUrl = authorizationUrl;
this.tokenUrl = tokenUrl;
}
// Generate code verifier and challenge (PKCE)
async generatePKCE() {
const randomBytes = new Uint8Array(32);
crypto.getRandomValues(randomBytes);
const verifier = btoa(String.fromCharCode(...randomBytes))
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
const challenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
return { verifier, challenge };
}
async startLogin() {
const { verifier, challenge } = await this.generatePKCE();
// Store verifier for callback
sessionStorage.setItem('pkce_verifier', verifier);
const params = new URLSearchParams({
response_type: 'code',
client_id: this.clientId,
redirect_uri: this.redirectUri,
code_challenge: challenge,
code_challenge_method: 'S256',
state: crypto.randomUUID(),
scope: 'openid profile email'
});
window.location.href = `${this.authorizationUrl}?${params}`;
}
async handleCallback(code) {
const verifier = sessionStorage.getItem('pkce_verifier');
sessionStorage.removeItem('pkce_verifier');
if (!verifier) {
throw new Error('PKCE verifier not found');
}
const response = await fetch(this.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: this.redirectUri,
client_id: this.clientId,
code_verifier: verifier
})
});
return response.json();
}
}
// Expected token response:
// {
// "access_token": "eyJhbGciOiJSUzI1NiIs...",
// "token_type": "Bearer",
// "expires_in": 3600,
// "refresh_token": "dGhpcyBpcyBhIHJlZnJl...",
// "id_token": "eyJhbGciOiJSUzI1NiIs..."
// }
Auth Context and Protected Routes
import { createContext, useContext, useState, useEffect } from 'react';
const AuthContext = createContext(null);
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check for existing session on mount
checkSession();
}, []);
async function checkSession() {
try {
const response = await fetch('/api/auth/session', {
credentials: 'include'
});
if (response.ok) {
const userData = await response.json();
setUser(userData);
}
} catch (error) {
console.error('Session check failed:', error);
} finally {
setLoading(false);
}
}
const value = {
user,
loading,
login: async (email, password) => {
const userData = await authService.login(email, password);
setUser(userData);
},
logout: async () => {
await authService.logout();
setUser(null);
},
isAuthenticated: !!user
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
if (loading) {
return <div>Checking authentication...</div>;
}
if (!user) {
return <Navigate to="/login" replace />;
}
return children;
}
function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
Common Mistakes
- Storing JWT in localStorage. Any XSS vulnerability can read tokens from localStorage. Always use HttpOnly cookies for refresh tokens and keep access tokens in memory.
- No refresh token rotation. A stolen refresh token that never changes gives the attacker permanent access. Rotate refresh tokens on each use.
- Not validating token expiration on the client. Check token expiry before making API calls to avoid unnecessary 401 responses and improve UX.
- Silent refresh failures without user notification. If token refresh fails silently, the user appears logged in but all API calls fail. Show appropriate error states.
- Missing CSRF protection on auth endpoints. Login, logout, and token refresh endpoints need CSRF protection. Use SameSite cookies and anti-CSRF tokens.
Practice Questions
- Why should refresh tokens be stored in HttpOnly cookies rather than localStorage?
- What is refresh token rotation and why is it important?
- How does the PKCE extension improve OAuth2 security for SPAs?
- What happens during silent token refresh when the user's session has expired?
- Why should you check token expiration on the client side?
Challenge: Build a complete auth system for an SPA with: JWT authentication with refresh token rotation in HttpOnly cookies, OAuth2 login with Google (PKCE flow), protected routes that redirect to login, automatic silent token refresh, and session persistence across browser tabs using BroadcastChannel API.
FAQ
Mini Project
Build a dashboard SPA with user authentication: login form with email/password, JWT access tokens stored in memory, refresh tokens in HttpOnly cookies with rotation, protected routes that redirect to login, user profile page that fetches data with the access token, and automatic logout when the session expires.
What's Next
You understand SPA authentication. Now explore SPA testing to write unit, integration, and end-to-end tests for your application.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro