Amblem
Furkan Baytekin

How You Should Store Tokens in SPA Web Apps

Best practices for handling access tokens and authentication

How You Should Store Tokens in SPA Web Apps
43
15 minutes

Single Page Applications (SPAs) are popular for their seamless user experiences and dynamic interfaces. However, handling sensitive data like access tokens in SPAs poses unique security challenges. Storing access tokens directly in the browser is generally discouraged due to inherent vulnerabilities. This article explores why storing access tokens in SPAs is risky, proposes a secure alternative using refresh tokens in HttpOnly cookies and in-memory access tokens, and explains how to implement CSRF protection for this approach.

1. Client-Side Vulnerabilities Increase Exposure Risk

SPAs run entirely in the browser, meaning all code and data, including access tokens, are processed client-side. This makes them susceptible to attacks like Cross-Site Scripting (XSS), where malicious scripts can be injected into the application. If an access token is stored in the browser (e.g., in localStorage, sessionStorage, or cookies), an attacker exploiting an XSS vulnerability could extract it.

2. Lack of Secure Storage Options

Browsers do not provide a truly secure storage mechanism for sensitive data like access tokens. Common storage options in SPAs include:

Without a tamper-proof storage mechanism, storing access tokens in the browser is inherently risky.

3. Increased Attack Surface in SPAs

SPAs often rely on third-party libraries, frameworks, and APIs, which can introduce vulnerabilities if not properly vetted or updated. A single compromised dependency could allow an attacker to access tokens stored in the browser.

4. Token Lifespan and Revocation Challenges

Access tokens typically have a short lifespan (e.g., 15–60 minutes) to minimize damage if compromised. However, SPAs often store these tokens for the duration of a user session, which could last hours. If a token is stolen, attackers can use it until it expires. Additionally, SPAs rarely implement robust token revocation mechanisms, as they rely on client-side logic that cannot securely verify token validity without backend support.

5. A Secure Alternative: Refresh Tokens in HttpOnly Cookies and Access Tokens in Memory

To mitigate the risks of storing access tokens in SPAs, a secure approach is to store a refresh token in an `Http //

Why You Shouldn’t Store Access Tokens in SPA Web Apps

Single Page Applications (SPAs) are popular for their seamless user experiences and dynamic interfaces. However, handling sensitive data like access tokens in SPAs poses unique security challenges. Storing access tokens directly in the browser is generally discouraged due to inherent vulnerabilities. This article explores why storing access tokens in SPAs is risky, proposes a secure alternative using refresh tokens in HttpOnly cookies and in-memory access tokens, and explains how to implement CSRF protection for this approach.

1. Client-Side Vulnerabilities Increase Exposure Risk

SPAs run entirely in the browser, meaning all code and data, including access tokens, are processed client-side. This makes them susceptible to attacks like Cross-Site Scripting (XSS), where malicious scripts can be injected into the application. If an access token is stored in the browser (e.g., in localStorage, sessionStorage, or cookies), an attacker exploiting an XSS vulnerability could extract it.

2. Lack of Secure Storage Options

Browsers do not provide a truly secure storage mechanism for sensitive data like access tokens. Common storage options in SPAs include:

Without a tamper-proof storage mechanism, storing access tokens in the browser is inherently risky.

3. Increased Attack Surface in SPAs

SPAs often rely on third-party libraries, frameworks, and APIs, which can introduce vulnerabilities if not properly vetted or updated. A single compromised dependency could allow an attacker to access tokens stored in the browser.

4. Token Lifespan and Revocation Challenges

Access tokens typically have a short lifespan (e.g., 15–60 minutes) to minimize damage if compromised. However, SPAs often store these tokens for the duration of a user session, which could last hours. If a token is stolen, attackers can use it until it expires. Additionally, SPAs rarely implement robust token revocation mechanisms, as they rely on client-side logic that cannot securely verify token validity without backend support.

5. A Secure Alternative: Refresh Tokens in HttpOnly Cookies and Access Tokens in Memory

To mitigate the risks of storing access tokens in SPAs, a secure approach is to store a refresh token in an HttpOnly, Secure, and SameSite cookie, use a refresh service to obtain an access token with credentials: 'include', and store the access token in memory (RAM). This method minimizes exposure while maintaining a seamless user experience.

Why This Approach Works

Implementation Overview

  1. Backend Setup:

    • Store refresh tokens securely in a database, associated with the user’s identity.
    • After authentication (e.g., via OAuth 2.0 or login), set a refresh token in an HttpOnly, Secure, SameSite cookie.
    • Create a /api/refresh endpoint to validate the refresh token and return a new access token.
  2. Frontend Setup:

    • Call the refresh endpoint with credentials: 'include' to obtain an access token.
    • Store the access token in a JavaScript variable (e.g., window.accessToken).
    • Use the access token in the Authorization header for API requests.
    • Handle 401 Unauthorized responses by refreshing the access token and retrying the request.
  3. Logout:

    • Invalidate the refresh token on the backend and clear the cookie.
    • Clear the in-memory access token on the frontend.

Example Implementation

Backend (Node.js/Express)

javascript
const express = require('express'); const app = express(); // Middleware to parse cookies app.use(require('cookie-parser')()); // Refresh endpoint app.post('/api/refresh', (req, res) => { const refreshToken = req.cookies.refreshToken; if (!refreshToken) return res.status(401).json({ error: 'No refresh token' }); // Validate refresh token (e.g., check database) const user = validateRefreshToken(refreshToken); // Implement this function if (!user) return res.status(403).json({ error: 'Invalid refresh token' }); // Generate new access token const accessToken = generateAccessToken(user); // Implement this function res.json({ accessToken }); }); // Login endpoint (example) app.post('/api/login', (req, res) => { // Validate credentials (e.g., username/password) const user = authenticateUser(req.body); // Implement this function if (!user) return res.status(401).json({ error: 'Invalid credentials' }); const refreshToken = generateRefreshToken(user); // Implement this function res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: true, sameSite: 'Strict', maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days }); const accessToken = generateAccessToken(user); res.json({ accessToken }); }); // Logout app.post('/api/logout', (req, res) => { const refreshToken = req.cookies.refreshToken; if (refreshToken) invalidateRefreshToken(refreshToken); // Implement this res.clearCookie('refreshToken', { httpOnly: true, secure: true, sameSite: 'Strict' }); res.json({ message: 'Logged out' }); });

Frontend (JavaScript)

javascript
// Refresh access token async function refreshAccessToken() { try { const response = await fetch('/api/refresh', { method: 'POST', credentials: 'include', // Include refresh token cookie }); const data = await response.json(); if (data.accessToken) { window.accessToken = data.accessToken; // Store in memory return data.accessToken; } throw new Error('Failed to refresh token'); } catch (error) { console.error('Error refreshing token:', error); // Redirect to login } } // Fetch protected data with retry async function fetchProtectedData(url) { let response = await fetch(url, { headers: { Authorization: `Bearer ${window.accessToken}` }, }); if (response.status === 401) { await refreshAccessToken(); response = await fetch(url, { headers: { Authorization: `Bearer ${window.accessToken}` }, }); } return response.json(); } // Logout async function logout() { await fetch('/api/logout', { method: 'POST', credentials: 'include', }); window.accessToken = null; // Clear access token }

6. Implementing CSRF Protection for the Refresh Endpoint

Since the refresh token is stored in a cookie, the /api/refresh endpoint is vulnerable to Cross-Site Request Forgery (CSRF) attacks, where a malicious site could trick a user’s browser into sending a request with the refresh token cookie. To prevent this, implement CSRF protection using a CSRF token.

How CSRF Protection Works

Implementation Steps

  1. Generate and Store CSRF Token:

    • On login, the backend generates a CSRF token and associates it with the user’s session or refresh token.
    • Store the CSRF token in a secure location (e.g., database or in-memory cache) and send it to the frontend in the login response.
  2. Send CSRF Token with Refresh Requests:

    • The frontend includes the CSRF token in a custom header (e.g., X-CSRF-Token) when calling the /api/refresh endpoint.
    • Use credentials: 'include' to send the refresh token cookie alongside the CSRF token.
  3. Validate CSRF Token on Backend:

    • The backend checks the CSRF token in the request header against the stored token.
    • Only process the refresh request if the token is valid.

Example Implementation with CSRF Protection

Backend (Node.js/Express)

javascript
const express = require('express'); const app = express(); const crypto = require('crypto'); app.use(require('cookie-parser')()); app.use(express.json()); // Store CSRF tokens (e.g., in-memory or database) const csrfTokens = new Map(); // Login endpoint app.post('/api/login', (req, res) => { const user = authenticateUser(req.body); // Implement this if (!user) return res.status(401).json({ error: 'Invalid credentials' }); const refreshToken = generateRefreshToken(user); // Implement this const csrfToken = crypto.randomBytes(32).toString('hex'); // Generate CSRF token csrfTokens.set(user.id, csrfToken); // Store CSRF token res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: true, sameSite: 'Strict', maxAge: 7 * 24 * 60 * 60 * 1000, }); res.json({ accessToken: generateAccessToken(user), csrfToken }); }); // Refresh endpoint with CSRF protection app.post('/api/refresh', (req, res) => { const refreshToken = req.cookies.refreshToken; const csrfToken = req.headers['x-csrf-token']; if (!refreshToken || !csrfToken) { return res.status(401).json({ error: 'Missing refresh token or CSRF token' }); } const user = validateRefreshToken(refreshToken); // Implement this if (!user || csrfTokens.get(user.id) !== csrfToken) { return res.status(403).json({ error: 'Invalid refresh token or CSRF token' }); } const accessToken = generateAccessToken(user); res.json({ accessToken }); }); // Logout app.post('/api/logout', (req, res) => { const refreshToken = req.cookies.refreshToken; if (refreshToken) { const user = validateRefreshToken(refreshToken); if (user) csrfTokens.delete(user.id); // Clear CSRF token invalidateRefreshToken(refreshToken); // Implement this } res.clearCookie('refreshToken', { httpOnly: true, secure: true, sameSite: 'Strict' }); res.json({ message: 'Logged out' }); });

Frontend (JavaScript)

javascript
// Store CSRF token (received during login) let csrfToken = null; // Login async function login(credentials) { const response = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials), }); const data = await response.json(); if (data.accessToken && data.csrfToken) { window.accessToken = data.accessToken; // Store in memory csrfToken = data.csrfToken; // Store CSRF token } } // Refresh access token with CSRF token async function refreshAccessToken() { try { const response = await fetch('/api/refresh', { method: 'POST', credentials: 'include', headers: { 'X-CSRF-Token': csrfToken }, // Include CSRF token }); const data = await response.json(); if (data.accessToken) { window.accessToken = data.accessToken; return data.accessToken; } throw new Error('Failed to refresh token'); } catch (error) { console.error('Error refreshing token:', error); // Redirect to login } } // Fetch protected data async function fetchProtectedData(url) { let response = await fetch(url, { headers: { Authorization: `Bearer ${window.accessToken}` }, }); if (response.status === 401) { await refreshAccessToken(); response = await fetch(url, { headers: { Authorization: `Bearer ${窓.accessToken}` }, }); } return response.json(); } // Logout async function logout() { await fetch('/api/logout', { method: 'POST', credentials: 'include', headers: { 'X-CSRF-Token': csrfToken }, }); window.accessToken = null; csrfToken = null; }

CSRF Protection Considerations

7. Other Safer Alternatives to Storing Tokens in SPAs

In addition to the refresh token approach, consider these alternatives:

8. Best Practices for SPA Security

To enhance security when using the refresh token approach:

Conclusion

Storing access tokens in SPA web apps is risky due to client-side vulnerabilities, lack of secure storage, and increased attack surfaces. Using an HttpOnly, Secure, and SameSite cookie for refresh tokens, obtaining access tokens via a refresh service with credentials: 'include', and storing access tokens in memory provides a secure and practical solution. Adding CSRF protection with a token-based approach ensures the refresh endpoint is safe from cross-site attacks. By combining this approach with XSS mitigation, HTTPS, and other best practices, developers can protect user data and maintain a seamless authentication experience in SPAs.


Album of the day:

Suggested Blog Posts