Your AI Put the JWT in localStorage: The Next.js httpOnly Cookie Fix (With Code)

July 29, 2026 · 2243 words

Ask Cursor, Claude Code, or v0 to "add login to my Next.js app" and you will almost certainly get working code within a minute. It signs a JWT, hands it to the browser, and the browser stashes it with localStorage.setItem('token', ...). You log in, the dashboard loads, and it feels finished.

It isn't. That one line means your users' login tokens are readable by any JavaScript running on your page — including JavaScript an attacker gets in. The fix is a Next.js JWT httpOnly cookie: the same token, moved somewhere the browser refuses to show to scripts. This guide shows the exact insecure pattern AI generates, why it's dangerous in plain language, and the full replacement code, verified against the current Next.js and jose docs.

Plain-language glossary

Before the code, three terms. No security background needed.

  • JWT (JSON Web Token) — a long string that says "this person is user #42", signed by your server so it can't be edited. Whoever holds it is that user, as far as your app is concerned. Think of it as a hotel keycard.
  • localStorage — a small storage box in the browser that JavaScript can read and write freely. Anything you put there, any script on your page can read. Keycard left on the front desk.
  • httpOnly cookie — a cookie the browser stores and automatically sends with every request, but which JavaScript cannot read at all. Keycard locked in the safe, where only the hotel staff can reach it.

The whole fix is: stop leaving the keycard on the desk.

Diagram comparing insecure JWT storage in localStorage where JavaScript can read the token versus a secure Next.js httpOnly cookie that JavaScript cannot access

The code your AI wrote

This is the pattern that comes out of nearly every "add auth to my Next.js app" prompt. Two files, both insecure.

// app/api/auth/login/route.ts — returns the token in the response body (insecure)
import { SignJWT } from 'jose'

const key = new TextEncoder().encode(process.env.JWT_SECRET)

export async function POST(request: Request) {
  const { email, password } = await request.json()
  const user = await verifyCredentials(email, password) // your own lookup

  if (!user) {
    return Response.json({ error: 'Invalid credentials' }, { status: 401 })
  }

  const token = await new SignJWT({ userId: user.id, role: user.role })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')
    .sign(key)

  // The token goes back as JSON, for the client to store however it likes
  return Response.json({ token })
}
// app/login/page.tsx — client component that parks the token in localStorage (insecure)
'use client'
import { useRouter } from 'next/navigation'

export default function LoginPage() {
  const router = useRouter()

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    const form = new FormData(e.currentTarget)

    const res = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        email: form.get('email'),
        password: form.get('password'),
      }),
    })

    const { token } = await res.json()
    localStorage.setItem('token', token) // ← the whole problem, one line
    router.push('/dashboard')
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <button type="submit">Log in</button>
    </form>
  )
}

Every later request then reads the token back out and attaches it by hand: Authorization: Bearer ${localStorage.getItem('token')}.

Why this is dangerous

The danger has a name: XSS, or cross-site scripting. That's when an attacker gets their own JavaScript to run on your page — through a comment field that renders raw HTML, a username shown without escaping, or a compromised npm package pulled in by your build.

Once any script runs on your page, localStorage.getItem('token') works for them exactly as well as it works for you. One line of injected JavaScript and every logged-in visitor's token is sent to a server the attacker controls. The tokens still validate. Your logs show normal, correctly signed requests. There is nothing to revoke, because a stateless JWT stays valid until it expires — and the AI set that to seven days.

This isn't a niche opinion. The OWASP HTML5 Security Cheat Sheet states it flatly: "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." It adds that a single XSS can be used to steal all the data in these objects.

An httpOnly cookie doesn't make XSS harmless. It makes the token itself unreachable. The attacker's script can still act as the user while they're on your page, but it cannot copy the keycard and walk out with it.

RiskWhat can happenFix
Token in localStorageOne XSS bug exports every user's token; attacker logs in as them from anywhereMove the token into an httpOnly cookie
Cookie sent over plain HTTPAnyone on the same café Wi-Fi reads the token in transitsecure: true in production
Cookie sent to other sitesA malicious site triggers actions as your logged-in user (CSRF)sameSite: 'lax' or 'strict'
7-day token, no revocationA stolen token works for a week; logout changes nothingShort expiry (hours) + refresh, or database sessions
Weak JWT_SECRETAttacker guesses the secret and forges any user's token256-bit random secret, never committed
Algorithm not pinned on verifyToken accepted under an algorithm you didn't intendPass algorithms: ['HS256'] when verifying

Attack flow diagram showing how a cross-site scripting bug lets an attacker steal a JWT from localStorage in four steps

The fix, step by step

Four short steps. The token stays a JWT — only its address changes.

Step 1: Generate a real secret

The OWASP JSON Web Token Cheat Sheet is specific here: for HS256, the secret must be at least as long as the output — 256 bits — generated by a cryptographically secure generator. A passphrase like mySecret2026 is not acceptable.

# Generates a 256-bit random hex secret
openssl rand -hex 32

Put it in .env.local as JWT_SECRET=... and confirm .env.local is in your .gitignore. If you're unsure how Next.js handles env vars — and especially why a NEXT_PUBLIC_ prefix would be catastrophic here — read our guide on Next.js environment variables and security next.

Step 2: One session helper for signing and verifying

// app/lib/session.ts
import { SignJWT, jwtVerify } from 'jose'

const key = new TextEncoder().encode(process.env.JWT_SECRET)

export type SessionPayload = { userId: string; role: string }

export async function encrypt(payload: SessionPayload) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('2h') // short, not 7 days
    .sign(key)
}

export async function decrypt(token: string | undefined) {
  if (!token) return null
  try {
    const { payload } = await jwtVerify<SessionPayload>(token, key, {
      algorithms: ['HS256'], // pin the algorithm — do not accept anything else
    })
    return payload
  } catch {
    // Expired, tampered with, or signed by someone else
    return null
  }
}

Two details worth knowing. Pinning algorithms: ['HS256'] is the documented defence against algorithm-confusion attacks; the jose docs also note that unsecured tokens ({"alg":"none"}) are never accepted by this API. And decrypt returning null instead of throwing means a bad token is simply "not logged in", not a 500 error.

The server sets the cookie itself and returns nothing sensitive. cookies() is async in Next.js 15 and later — you must await it, per the official cookies reference.

// app/api/auth/login/route.ts — secure version
import { cookies } from 'next/headers'
import { encrypt } from '@/app/lib/session'

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

  if (!user) {
    // Same message for wrong email and wrong password — don't leak which
    return Response.json({ error: 'Invalid credentials' }, { status: 401 })
  }

  const token = await encrypt({ userId: user.id, role: user.role })
  const cookieStore = await cookies()

  cookieStore.set('session', token, {
    httpOnly: true,                                  // JavaScript cannot read it
    secure: process.env.NODE_ENV === 'production',    // HTTPS only in prod
    sameSite: 'lax',                                 // not sent from other sites
    path: '/',
    maxAge: 60 * 60 * 2,                             // match the token: 2 hours
  })

  return Response.json({ ok: true }) // no token in the body
}

Your client component now drops the localStorage line entirely. It doesn't touch the token at all — the browser attaches the cookie to every subsequent request automatically.

const res = await fetch('/api/auth/login', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email, password }),
})
if (res.ok) router.push('/dashboard')
// No localStorage. No token variable. Nothing to steal.

Step 4: Read the session on the server, and log out properly

// app/lib/auth.ts
import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'

export async function getSession() {
  const cookieStore = await cookies()
  return decrypt(cookieStore.get('session')?.value)
}
// app/api/auth/logout/route.ts
import { cookies } from 'next/headers'

export async function POST() {
  const cookieStore = await cookies()
  cookieStore.delete('session')
  return Response.json({ ok: true })
}

Now every protected page or Route Handler calls getSession() and returns 401 when it's null. Do this in each one — not only in middleware. A cookie check in middleware is an optimistic filter, and the Next.js docs are explicit that it "should not be your only line of defense". We covered why that gap is so easy to leave open in Next.js middleware is not enough to protect your routes.

How to test the fix

Three checks, one minute.

1. Confirm JavaScript can't see the token. Log in, open DevTools, and run this in the console:

document.cookie                    // should NOT contain "session"
localStorage.getItem('token')      // should be null

If session appears in document.cookie, the httpOnly flag didn't apply.

2. Confirm the server sends the right flags. Watch the Set-Cookie header on the login response:

curl -i -X POST http://localhost:3000/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"your-password"}'

You want a line like Set-Cookie: session=eyJ...; Path=/; HttpOnly; SameSite=Lax — and Secure too, once you're on HTTPS. You want no token in the JSON body.

3. Confirm protected routes reject a missing cookie.

curl -i http://localhost:3000/api/dashboard   # expect 401

Ask your AI to fix it

Paste this into Cursor or Claude Code on an existing project:

Audit this Next.js App Router project for insecure session-token storage. Find every place a JWT or auth token is written to or read from localStorage or sessionStorage, and every endpoint that returns a token in a response body. Replace all of it with an httpOnly cookie set server-side via cookies() from next/headers, using httpOnly: true, secure in production, sameSite: 'lax', path: '/', and a maxAge matching the token expiry. Use jose for signing and verification, pin algorithms: ['HS256'] on verify, and set expiry to 2 hours instead of days. Add a server-side getSession() helper and call it inside every protected Route Handler and page — do not rely on middleware alone. Show me the diff before applying anything.

Ask for the diff. Reviewing what changed is the habit that catches the next one, too.

Checklist

Reference table of Next.js session cookie settings — httpOnly, secure, sameSite, path, maxAge and token expiry — with the risk of omitting each one

  • No localStorage or sessionStorage call anywhere near an auth token
  • Login endpoint returns no token in its response body
  • Cookie set with httpOnly: true
  • secure: true in production
  • sameSite: 'lax' or 'strict'
  • maxAge matches the JWT's expiry — cookie never outlives the token
  • Token expiry in hours, not days
  • JWT_SECRET is 256-bit random, in .env.local, .gitignored, no NEXT_PUBLIC_ prefix
  • algorithms: ['HS256'] pinned on every verify
  • Verification failure returns "not logged in", not a crash
  • Every protected route calls getSession() itself
  • Logout deletes the cookie server-side

Storage is one layer. The broader picture — what else AI leaves out of auth code, and which libraries handle this for you — is in our pillar guide to vibe coding security and our roundup of secure authentication tools for vibe coding.


Want a second pair of eyes on your auth code? If you've shipped a Next.js app with AI-generated login and you're not sure where the token ends up, I'll do a free hands-on review of your session handling — cookie flags, token expiry, secret hygiene, and whether your protected routes actually check. Email [email protected] with your stack and the files you're unsure about.

Key Takeaway

A Next.js JWT httpOnly cookie is a one-file change that removes an entire category of attack. AI assistants default to localStorage because it's the shortest path to working code — but it hands your users' tokens to any script that gets on the page, and OWASP has said not to do it for years. Set the cookie server-side with httpOnly, secure, and sameSite, keep expiry short, pin the algorithm, and check the session in every protected route. Then run the three tests above so you know it worked rather than hoping.


Sources verified July 2026: Next.js cookies API reference, Next.js authentication guide, OWASP HTML5 Security Cheat Sheet, OWASP JSON Web Token Cheat Sheet, and the jose library docs (~7.7k stars, as of July 2026).