Authentication Best Practices in Single-Page Applications (SPA)
Single-page applications (SPAs) rely heavily on tokens for authentication. Unlike server-rendered applications, most logic in SPAs runs in the browser, making token storage a critical part of securing user sessions.
Understanding Token Roles
SPAs commonly use two types of tokens: access tokens and refresh tokens. Each has a distinct purpose and lifetime.
- •Access Token: Short-lived (usually a few minutes). Used to authenticate API calls.
It should be stored only in memory to minimize exposure to cross-site scripting (XSS) attacks. Once the page is refreshed, it’s lost — and that’s a good thing.
- •Refresh Token: Long-lived (days or weeks). Used to obtain new access tokens when the current one expires.
It should be stored in a HttpOnly cookie with Secure and SameSite flags to prevent unauthorized access and cross-site request forgery (CSRF).
This split-storage pattern balances security and usability. Access tokens stay safe in volatile memory, while refresh tokens remain protected in cookies.
Token Rotation and Lifecycle Management
Every time a new refresh token is issued, the previous one should be invalidated. This process, known as token rotation, ensures that stolen or leaked tokens quickly become useless. Short access token lifetimes reduce the window for replay attacks, and rotating refresh tokens prevent persistent compromise. Together, they form the backbone of modern token-based authentication.
Implementing Secure Token Handling in SPAs
A practical implementation stores the access token in memory and uses cookies (with HttpOnly) for the refresh token.
Here’s an example using Axios:
// auth.ts
import axios from 'axios';
let accessToken: string | null = null;
export async function login(credentials: { email: string; password: string }) {
const res = await axios.post('/api/login', credentials, { withCredentials: true });
accessToken = res.data.accessToken;
}
export async function fetchProtectedResource() {
try {
const res = await axios.get('/api/protected', {
headers: { Authorization: `Bearer ${accessToken}` },
});
return res.data;
} catch (err) {
if (err.response?.status === 401) {
await refreshToken();
return fetchProtectedResource();
}
throw err;
}
}
async function refreshToken() {
const res = await axios.post('/api/refresh', null, { withCredentials: true });
accessToken = res.data.accessToken;
}In this flow:
- •The access token exists only in memory.
- •The refresh token stays in a secure,
HttpOnlycookie. - •When the access token expires (401), the client silently refreshes it using the cookie and retries the request.
This ensures a seamless experience without exposing sensitive tokens to the browser.
Strengthening Security
Token safety goes beyond storage — it’s also about browser hygiene and backend validation.
- •Implement Content Security Policy (CSP) headers and sanitize all user inputs to reduce XSS risk.
- •Use
SameSite=Strictfor cookies if your SPA is same-origin; chooseLaxor CSRF tokens for cross-origin setups. - •Keep access tokens short-lived and refresh them quietly in the background.
- •Always validate tokens on the server side, not just in the frontend.
Building a Resilient SPA Authentication Flow
A robust SPA authentication flow should handle failures gracefully and automatically recover when possible. When an expired token is detected, refresh silently without interrupting the user experience. If refresh fails (e.g., cookie expired or revoked), redirect the user to log in again. Combine silent refresh with secure cookie practices to create a frictionless yet hardened authentication model.
Final Thoughts
Authentication in SPAs is a balance between security and convenience. Keep access tokens ephemeral, protect refresh tokens in HttpOnly cookies, and rotate them frequently. Harden your app with CSP headers, safe cookie settings, and strict validation. A secure authentication flow doesn’t just protect your users — it makes your entire system more predictable, maintainable, and trustworthy.