Cookies vs JWT for Authentication: The 2026 Developer Guide

Written By  Crosscheck Team

Content Team

May 22, 2026 12 minutes

Cookies vs JWT for Authentication: The 2026 Developer Guide

Cookies vs JWT Authentication Explained for 2026

The cookies-vs-JWT debate is usually framed as a binary choice — it isn't. A session cookie is an opaque identifier the browser sends with every request to a single origin, and the server keeps the matching state. A JWT (JSON Web Token) is a signed, self-contained payload that carries claims the server can verify without a database lookup. Most modern apps use both: a short-lived JWT as the access token, and an HttpOnly cookie to carry the refresh token. The interesting question isn't "which one" — it's where each piece lives, and which attack surface you're choosing to defend.

Key takeaways

  • Cookies are a transport mechanism; JWTs are a token format. They solve different problems and frequently appear together.
  • Where you store a JWT decides your threat model. localStorage exposes it to any XSS. An HttpOnly cookie hides it from JavaScript but introduces CSRF surface.
  • The current 2026 gold standard is an access token in memory plus a refresh token in an HttpOnly, Secure, SameSite cookie — with refresh-token rotation and reuse detection.
  • SameSite=Lax is Chrome's default since version 80, but Firefox and Safari still default to None and layer their own tracking protections on top. Always set the attribute explicitly.
  • OWASP and RFC 8725 explicitly warn against alg: none, demand audience and issuer validation, and recommend short expirations with a revocation strategy for anything long-lived.

What a session cookie actually is

A session cookie is a small string the browser stores per origin and sends back in a Cookie header on every subsequent request to that origin. The string itself is meaningless — it's a random identifier. The server keeps the real data (user id, roles, last-active timestamp, anything else) in its own session store, keyed by that identifier.

Set-Cookie: sid=8f2a3b9e7c1d5a4f; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400

When the browser returns later, it sends:

Cookie: sid=8f2a3b9e7c1d5a4f

The server looks up sid in Redis, Postgres, or memcached, finds the session row, and authenticates the request. To log a user out you delete the row. To revoke a session you delete the row. To rotate a session you replace it. State lives server-side, and that statefulness is the whole point.

The browser handles cookies automatically — it honours the attributes, attaches them to the right requests, refuses to leak them across origins, and prevents JavaScript from reading them when you say HttpOnly. That's two decades of mature, audited browser behaviour you don't have to reimplement.


Cookie attributes that actually matter

A cookie without attributes is a 1990s cookie. The attributes are where the security work happens.

AttributeWhat it doesDefaultSet it to
HttpOnlyBlocks document.cookie from JavaScriptoffon for any auth cookie
SecureOnly sent over HTTPSoffon in production
SameSiteControls cross-site sendingvariesLax for most, Strict for high-value, None only with Secure
DomainWhich subdomain(s) get itcurrent hostleave unset unless you need subdomains
PathWhich paths get it/usually leave at /
Max-Age / ExpiresPersistence and lifetimesession-onlyexplicit duration tied to your refresh policy

A correctly-set auth cookie in 2026 looks like this on the wire:

Set-Cookie: refresh_token=eyJhbGc...; HttpOnly; Secure; SameSite=Strict; Path=/auth; Max-Age=2592000

And in Node + Express:

res.cookie('refresh_token', token, {
  httpOnly: true, // not readable from JS
  secure: true, // HTTPS only
  sameSite: 'strict', // not sent on cross-site requests
  path: '/auth', // only sent to refresh endpoints
  maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
});

SameSite: the attribute everyone gets wrong

SameSite controls whether the browser includes the cookie on cross-site requests — the same mechanism that defines whether CSRF is even possible.

  • SameSite=Strict — never sent on cross-site requests, including top-level navigation. The link from a Slack DM to your dashboard arrives unauthenticated, so the user sees the logged-out page even though they're logged in. Strict makes sense for things like a refresh-token cookie scoped to /auth.
  • SameSite=Lax — sent on top-level GET navigations but not on cross-site POST, fetch, or iframe requests. Lax is the practical default for general session cookies.
  • SameSite=None — sent on every cross-site request. Required for third-party embeds, OAuth flows, and any cookie you genuinely want sent cross-origin. Must be paired with Secure — every major browser silently drops SameSite=None cookies without it.

Defaults are not consistent across browsers. Chromium has defaulted unset cookies to Lax since Chrome 80 in February 2020, per the Chromium project's SameSite FAQ. Firefox attempted the same change, reverted it because of breakage, and currently defaults to None. Safari likewise defaults to None and relies on Intelligent Tracking Prevention. The takeaway: set the attribute explicitly on every cookie. Don't let the browser pick.


What a JWT actually is

A JWT is three base64url-encoded strings joined by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1XzEyMyIsImV4cCI6MTcwMDAwMDAwMH0.4PqXp0...
header                                  payload                                signature

Decode the first segment and you get the header — algorithm and token type:

{ "alg": "HS256", "typ": "JWT" }

The second segment is the payload — the claims:

{
  "sub": "u_123",
  "iss": "https://auth.example.com",
  "aud": "api.example.com",
  "exp": 1700000000,
  "iat": 1699996400,
  "role": "admin"
}

The third segment is the signature — an HMAC or asymmetric signature over base64url(header) + "." + base64url(payload), computed with a secret (HS256) or a private key (RS256, ES256). Anyone with the signing key can mint tokens; anyone with the verifying key (the same key for HMAC, the public key for asymmetric) can check them.

The payload is not encrypted, only signed. Anyone who intercepts a JWT can read every claim in it. Treat the payload as public.

Signing and verifying in practice

Most teams use jose (the modern, audited library) for both signing and verifying.

import { SignJWT, jwtVerify } from 'jose';

const secret = new TextEncoder().encode(process.env.JWT_SECRET);

// Sign
const token = await new SignJWT({ role: 'admin' })
  .setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
  .setSubject('u_123')
  .setIssuer('https://auth.example.com')
  .setAudience('api.example.com')
  .setIssuedAt()
  .setExpirationTime('15m')
  .sign(secret);

// Verify
const { payload } = await jwtVerify(token, secret, {
  issuer: 'https://auth.example.com',
  audience: 'api.example.com',
});

Two non-obvious points. First, always pass the expected algorithm explicitly — never let the library trust the alg header from the token. The classic JWT attack is changing alg to none or downgrading RS256 to HS256 and signing with the public key. RFC 8725 — JSON Web Token Best Current Practices calls these out as the first two threats it addresses. Second, always validate iss, aud, and exp. A signed token that's expired or intended for a different service is not valid.

Why "stateless" is overhyped

The most-quoted JWT pitch is statelessness — verify the signature, trust the claims, skip the database read. That's useful in distributed systems where you don't want every service hitting the same session table. It's also why JWTs are awkward to revoke: there's no row to delete. If a token leaks at 9:00am and expires at 10:00am, the attacker has an hour unless you maintain a server-side denylist (which brings back most of the state you were trying to avoid).

Auth0, Clerk, and Okta settle this the same way in 2026: keep the access token short (60 seconds to 15 minutes), and put the slow, revocable part of the session behind a refresh token in an HttpOnly cookie.


Cookies vs JWTs: the actual comparison

DimensionSession cookieJWT
What it carriesOpaque IDSigned claims
StateServer-side (session store)Stateless (claims in the token)
Storage on clientCookie (browser-managed)Anywhere — cookie, localStorage, memory
RevocationTrivial (delete the row)Hard (need denylist or short TTL)
XSS exposureNone if HttpOnlyFull if in localStorage
CSRF exposureYes, needs SameSite + CSRF token if mutatingNone if in Authorization header, yes if in cookie
Scales to many servicesNeeds shared session storeVerifies anywhere with the public key
Mobile / native fitAwkward — no cookie jar on some clientsNative fit — pass in Authorization header
Payload size on the wire~32 bytesHundreds of bytes to a few KB
OAuth / OIDC integrationIndirectNative — ID Tokens are JWTs

Read across the table once and the pattern is clear: cookies are operationally simple but bound to the browser. JWTs are flexible and portable but shift complexity onto you to handle revocation, storage, and rotation correctly. Neither is "more secure" in the abstract — they fail differently.


XSS, CSRF, and the storage decision

Where you put the token decides which class of attack you're defending against. The two relevant attacks are XSS (an attacker runs JavaScript on your origin) and CSRF (an attacker tricks the user's browser into making an authenticated request to your origin).

Storing the JWT in localStorage

Any script running on your origin can call localStorage.getItem('token') — including XSS payloads, a compromised CDN, an analytics tag with a bad release, or a vulnerable npm dependency. If your token is in localStorage, an XSS bug is an account takeover. There is no HttpOnly equivalent for localStorage. OWASP's JWT testing guide and the Authentication Cheat Sheet both warn against the pattern.

Storing the JWT in an HttpOnly cookie

The cookie is invisible to JavaScript, which closes the XSS exfiltration path. The trade is that the browser sends it automatically on every request to your origin — including cross-site form posts. That's CSRF. Mitigate with SameSite=Lax or Strict, and for state-changing endpoints add a CSRF token (double-submit cookie or synchronizer token pattern).

Storing the access token in memory, refresh token in HttpOnly cookie

This is the pattern Auth0, Clerk, Supabase, and most modern auth libraries settle on. The access token lives in a JavaScript variable — gone on page refresh, but recoverable via a silent refresh call. The refresh token sits in an HttpOnly, Secure, SameSite cookie scoped to /auth/refresh. XSS can't dump the access token from localStorage (it isn't there) and can't read the refresh token at all. CSRF only matters for the refresh endpoint, mitigated by SameSite=Strict on that cookie.

The pattern can be pushed further into the Backend-for-Frontend (BFF) model: the browser holds nothing but a session cookie, and a small server-side BFF holds the tokens and proxies API calls. Tokens never touch the browser — the strongest setup, and the one OAuth's Security Best Current Practice (RFC 9700) leans toward for browser SPAs.


Refresh tokens, rotation, and why short access tokens matter

A refresh token is a long-lived credential whose only job is to get you a fresh, short-lived access token. The split exists because the two tokens have different threat models.

  • Access token — sent on every API request. Short TTL (60s to 15min). If it leaks, the window of damage is small. Stateless, validated by signature.
  • Refresh token — sent only to the refresh endpoint. Long TTL (days to weeks). Stored server-side or as a rotating opaque identifier. If it leaks, you can revoke it.

Refresh token rotation

On every refresh, the server issues a new access token and a new refresh token, and invalidates the old one. If an attacker steals a refresh token and uses it, the next time the legitimate user refreshes, the server sees an already-used refresh token and detects the theft. Standard practice is to invalidate the entire token family on detected reuse and force re-authentication.

// Pseudocode for a rotation-aware refresh endpoint
async function refresh(req, res) {
  const oldToken = req.cookies.refresh_token;
  const record = await db.refreshTokens.findOne({ token: hash(oldToken) });

  if (!record) return res.status(401).end();

  if (record.usedAt) {
    // Reuse detected — revoke the whole family
    await db.refreshTokens.revokeFamily(record.familyId);
    return res.status(401).end();
  }

  await db.refreshTokens.markUsed(record.id);

  const newAccess = await signAccessToken(record.userId);
  const newRefresh = await issueRefreshToken(record.userId, record.familyId);

  res.cookie('refresh_token', newRefresh, REFRESH_COOKIE_OPTIONS);
  res.json({ access_token: newAccess });
}

Server-side revocation for JWTs

Because a JWT is self-contained, the issuer can't simply forget it. The three workable strategies:

  1. Short expiry only — accept that a leaked token works for a few minutes.
  2. Token denylist — on logout or session kill, push the JWT's jti onto a Redis set with TTL equal to the token's remaining lifetime. Every API request checks the list. Brings state back, but the state is small and bounded.
  3. Reference tokens — the "JWT" is actually an opaque identifier and the server materialises claims on each request. What Auth0 calls "reference tokens" and what most BFFs effectively do.

Where OAuth and OpenID Connect fit

OAuth 2.0 is the authorisation framework — how a third-party app gets permission to call an API on a user's behalf. OpenID Connect (OIDC) is the identity layer on top, defining how that app actually authenticates the user. In practice:

  • OAuth access tokens are usually JWTs (or opaque tokens treated like JWTs by the issuer).
  • OIDC ID tokens are always JWTs — the spec mandates it. The ID token is what proves "Google says this user is logged in."
  • Refresh tokens in OAuth are opaque by spec — never JWTs.

If you integrate with Google, GitHub, Microsoft, or Okta, you're consuming JWTs whether you wanted to or not. The job is to validate them correctly: check iss, check aud, fetch the JWKS from the provider's /.well-known/jwks.json, verify the signature, check exp. Libraries like jose, openid-client, and oidc-client-ts do this for you.

The current best-practice OAuth flow for browser apps is Authorization Code with PKCE, defined in RFC 7636 and reinforced by RFC 9700. The implicit flow is dead — don't use it.


Common mistakes worth naming

Things production teams get wrong, over and over.

Trusting alg from the token header. Always pass the expected algorithm to the verifier. Never call decode and treat the result as authenticated. The Node.js jsonwebtoken library historically defaulted to permissive algorithm handling and caused real breaches.

Storing JWTs in localStorage "because it's easier." It is easier — and the day an XSS bug ships you lose every session in your user base.

Forgetting Secure on SameSite=None. The cookie is silently dropped. Auth appears to randomly fail.

No revocation strategy. A 30-day JWT with no denylist is valid for 30 days. Pair short access tokens with revocable refresh tokens, or maintain a jti denylist.

Putting PII in the JWT. The payload is base64, not encrypted. Email, phone, address — none of it belongs in a token that ends up in browser memory and proxy logs.

Validating exp but not iss or aud. A signed JWT from one service can be accepted by another if both share a key and you only check expiration. The audience claim is what stops that.


A decision guide

Walk it top-down. Stop at the first match.

  1. Single backend, one domain, classic web app? Session cookies. HttpOnly, Secure, SameSite=Lax.
  2. SPA talking to your own API on the same registrable domain? Session cookies still win. Pair with CSRF tokens for state-changing routes.
  3. SPA talking to a separate API domain you control? Access token in memory, refresh token in an HttpOnly cookie scoped to the auth endpoint. Use a BFF if you can.
  4. Mobile or native client? JWT in the Authorization: Bearer header. Refresh token in secure platform storage (Keychain, Keystore).
  5. Many services, want to skip a session lookup per request? Short JWT access tokens, validated locally via JWKS. Maintain a jti denylist for logout.
  6. Integrating with an OAuth/OIDC provider? Whatever they hand you — usually a JWT ID token plus a JWT or opaque access token. Validate the ID token, store nothing in localStorage.

For more on the bugs that show up when these patterns go wrong, see JavaScript debugging tips and how to debug a web application step by step.


FAQ

Is a JWT more secure than a session cookie?

No. They have different threat models. A session cookie with HttpOnly, Secure, and SameSite set correctly is at least as secure as a JWT for a same-origin web app, and easier to revoke. JWTs win on scale and portability across services, not on raw security.

Can I put a JWT inside a cookie?

Yes — and for refresh tokens it's the recommended pattern. The cookie wrapper gives you HttpOnly protection and automatic transmission; the JWT inside lets the server verify the token statelessly. Just be aware you now have CSRF surface, so set SameSite and add CSRF tokens for state-changing routes.

Why is SameSite=Strict not the default?

It breaks normal navigation. A user clicking a link to your dashboard from an email gets sent there as if they were logged out, even though their session is valid in another tab. Lax is the compromise — sends the cookie on top-level GETs, blocks cross-site POST, which is where most CSRF lives.

How long should my access token live?

Short. 5 to 15 minutes is typical. The shorter the access token, the less damage a leaked one can do — and the refresh-token flow exists precisely to make short access tokens painless.

Do I need a refresh token if my session is short?

If your access token lives 15 minutes and your users expect to stay logged in for a week, yes — without a refresh token you'd force a re-login every 15 minutes. If your access token can live as long as the session (a back-office admin tool used only during a single shift, say), you may not need refresh tokens at all.

What about the alg: none attack — is it still a thing?

Yes, in libraries with permissive defaults. Always specify the expected algorithm at verify time. RFC 8725 section 3.1 makes this an explicit recommendation. Modern libraries like jose refuse none unless you go out of your way to enable it.

Should I use a managed auth provider instead?

For most teams, yes. Auth0, Clerk, Supabase, and WorkOS handle session management, token rotation, JWKS rotation, and security patching automatically. The 2025 Next.js middleware bypass (CVE-2025-29927) is a reminder that hand-rolled auth has a steady stream of foot-guns.


Where Crosscheck fits

Auth bugs are some of the worst to debug — a stale cookie on one device, a missing Secure flag in one environment, a refresh-token loop that only happens at 3am. They're also where reproduction context matters most: the exact cookies the browser had, the network calls that fired, the console errors that scrolled past. Crosscheck is a free Chrome extension that captures screenshots, screen recordings, console logs, network logs, and browser metadata into a single bug report sent straight to Jira, Linear, ClickUp, GitHub, or Slack — so a 401 in production arrives with headers and timing already attached.

Try Crosscheck free

Related Articles

Contact us
to find out how this model can streamline your business!
Crosscheck Logo
Crosscheck Logo
Crosscheck Logo

Speed up bug reporting by 50% and
make it twice as effortless.

Overall rating: 5/5