Session vs JWT: Which Should You Use in Next.js 16?
You're building a login flow in Next.js 16, and you hit the question every auth tutorial glosses over: should the logged-in state live in a session or a JWT? Half the guides you find use one, half use the other, and almost none of them tell you why — or what breaks if you pick wrong.
Here's the annoying part: both approaches look nearly identical from the outside. A cookie gets set, the user stays logged in, everyone's happy. The difference only shows up later — when you need to log a user out of every device instantly, when your app scales past one server, or when you're debugging why a "revoked" user can still hit your API for another six days.
By the end of this guide, you'll know exactly how sessions and JWTs work under the hood, see real, working code for both in Next.js 16 — using jose, Server Actions, and proxy.ts — and know which one actually fits your project instead of copying whatever the last tutorial you read happened to use.
Why This Is Tricky
Most explanations of "session vs JWT" get stuck comparing them as if they're two totally separate technologies. They're not. In both approaches, the browser stores a cookie. The real difference is what that cookie contains, and where the source of truth lives:
- Session-based auth: the cookie holds a random ID. The actual user data lives in your database. Every request that needs to know "who is this?" looks it up server-side.
- JWT-based auth: the cookie holds the user data itself, cryptographically signed. Your server can verify it's untampered without ever touching a database.
That one distinction — lookup vs. self-contained — is what drives every tradeoff in this post: revocation, scaling, latency, and how much you need to trust the cookie itself.
💡 Tip: If you've used Auth.js (NextAuth v5), you've already used both without necessarily noticing — its
session: { strategy: "jwt" }andsession: { strategy: "database" }options are exactly this same choice, just configured for you.
Prerequisites
This guide uses:
- Next.js 16 with the App Router
- TypeScript in strict mode
- React Compiler enabled (no manual
useMemo/useCallbackneeded) - Tailwind CSS for the (minimal) UI examples
You don't need a real database to follow along — the JWT section has zero dependencies, and the session section uses a generic db client you can swap for Postgres, Supabase, or whatever you're already using.
How Sessions Work
💡 Think of it like: a coat check ticket. The ticket itself is just a number — it means nothing on its own. The actual coat (your user data) stays with the attendant (your database). Lose the ticket, and the attendant can also just... stop honoring it. Instantly.
- User logs in with the right credentials.
- Your server generates a random token, stores it in a
sessionstable alongside the user's ID and an expiry, and sends that token back as anhttpOnlycookie. - On every request, your server takes the cookie value, looks it up in the database, and either finds a valid session or doesn't.
- To log someone out — including forcibly, from your side — you delete the row. Done. The cookie is now worthless.
The tradeoff: every authenticated request costs a database round trip (unless you cache it), but you get instant, guaranteed revocation.
How JWTs Work
💡 Think of it like: a sealed, signed letter. Anyone can read what's inside once it's opened, but nobody can forge your signature or quietly edit the contents without breaking the seal. The letter is self-sufficient — you don't need to call anyone to confirm it's real, just check the seal.
- User logs in with the right credentials.
- Your server creates a token containing the user's ID (and whatever else you want) and signs it with a secret key.
- On every request, your server checks the signature against that same secret. If it matches, the data inside is trusted — no database lookup required.
- To log someone out, you delete the cookie client-side. But if someone already has a copy of that token, it's still valid — and still verifiable — until it naturally expires. There's no built-in way to say "actually, ignore this one."
The tradeoff: verification is fast and needs no database, but you give up the ability to instantly kill a single session without extra machinery.
⚠️ Common Mistake: Calling a JWT stored in a cookie a "session" and assuming it behaves like one. It doesn't — a JWT has no server-side record at all unless you deliberately build one (see the revocation section below). If your mental model is "session," you'll be surprised the first time you try to force-logout a user and nothing happens.
⚠️ Cookie size limits: browsers cap a single cookie at roughly 4KB, and most reverse proxies (Nginx, Cloudflare) reject requests once the total header size gets much past 8KB. A session-ID cookie is always tiny — it's just a random string. A JWT cookie grows with every claim you cram into the payload (roles, permissions, profile fields). Stuff enough into it and the cookie silently fails to set, or your CDN starts 400-ing requests, with no obvious error pointing back at "your JWT got too big." Keep JWT payloads minimal — an ID and maybe a role, not a serialized user object.
Step 1: Set Up the Shared Pieces
Both approaches need somewhere to keep a signing secret. Add one to your environment file:
# .env.local
SESSION_SECRET=your-long-random-secret-here
Generate a strong value instead of typing something memorable:
node -e "console.log(crypto.randomBytes(32).toString('hex'))"
⚠️ Common Mistake: Committing
SESSION_SECRETto git, or reusing your.env.localvalue in production. Treat it exactly like a database password — if it leaks, an attacker can forge valid sessions for any user, for either approach below.
Step 2: Build JWT-Based Auth with jose
jose is the standard library for signing and verifying JWTs in an Edge-compatible way — it uses the Web Crypto API instead of Node's crypto module, so it works identically in Server Actions, Route Handlers, and proxy.ts.
npm install jose
Create the sign/verify helpers
// lib/jwt/session.ts
import "server-only";
import { SignJWT, jwtVerify } from "jose";
const secretKey = process.env.SESSION_SECRET;
const encodedKey = new TextEncoder().encode(secretKey);
export async function encrypt(userId: string, expiresAt: Date) {
return new SignJWT({})
.setProtectedHeader({ alg: "HS256" })
.setSubject(userId) // sub — who this token belongs to
.setIssuedAt() // iat — when it was issued
.setExpirationTime(expiresAt) // exp — when it stops being valid
.sign(encodedKey);
}
export async function decrypt(token: string | undefined = "") {
try {
const { payload } = await jwtVerify(token, encodedKey, {
algorithms: ["HS256"],
});
return payload; // payload.sub is the userId; iat/exp are unix seconds
} catch {
// Invalid signature, tampered payload, or expired token
return null;
}
}
What's happening here: instead of a custom userId field, this uses the standard, registered JWT claims — sub (subject, via .setSubject()), iat (issued-at, via .setIssuedAt()), and exp (expiry, via .setExpirationTime()). These three are part of the JWT spec itself, not a jose-specific convention, so any JWT library — in any language — knows how to read them. jwtVerify automatically checks exp and rejects an expired token before your code ever sees the payload, so you never write manual date comparisons.
⚠️ Signing is not encryption.
SignJWT— despite this file calling the wrapper functionsencrypt/decryptfor readability — does not hide the payload. A JWT's contents are only base64url-encoded, not encrypted, so anyone who intercepts the cookie (or just opens DevTools → Application → Cookies) can decode and readsub,iat, andexpin plain text. What the signature guarantees is that the payload wasn't tampered with — change one character andjwtVerifyrejects it. Never put anything genuinely secret (passwords, tokens for other services, PII you wouldn't want exposed) inside a signed JWT. If you actually need to hide the contents too, that's a different primitive — JWE — whichjosesupports separately viaEncryptJWT/jwtDecrypt.
💡 Do you still need
setExpirationTimeif you also track expiry elsewhere? No — and the version above deliberately avoids the redundancy. Compute the expiry date once, and hand that sameDateto bothsetExpirationTime()(for the JWT's ownexpclaim) and the cookie'sexpiresoption, as shown in the next step. Two separately-computed "7 days from now" values can drift apart by milliseconds and give you two conflicting sources of truth for the same thing — pick one.
Create the session cookie on login
// lib/jwt/actions.ts
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { encrypt } from "./session";
export async function createSession(userId: string) {
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const session = await encrypt(userId, expiresAt); // same Date used for exp and the cookie
const cookieStore = await cookies();
cookieStore.set("session", session, {
httpOnly: true,
secure: true,
expires: expiresAt,
sameSite: "lax",
path: "/",
});
}
export async function deleteSession() {
const cookieStore = await cookies();
cookieStore.delete("session");
redirect("/login");
}
What's happening here: httpOnly: true keeps the cookie invisible to client-side JavaScript, which stops it from being stolen via an XSS attack. secure: true means it's only ever sent over HTTPS. sameSite: "lax" blocks the cookie from being attached to most cross-site requests, which is your main defense against CSRF here. None of this is JWT-specific — you'd want the exact same flags on a session-ID cookie too.
Verify the session with a Data Access Layer
Don't scatter decrypt() calls across every page. Centralize verification in one cached function — this is what Next.js's own docs call a Data Access Layer (DAL).
// lib/jwt/dal.ts
import "server-only";
import { cache } from "react";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { decrypt } from "./session";
export const verifySession = cache(async () => {
const cookieStore = await cookies();
const token = cookieStore.get("session")?.value;
const session = await decrypt(token);
if (!session?.sub) {
redirect("/login");
}
return { isAuth: true, userId: session.sub };
});
What's happening here: wrapping this in React's cache() means that no matter how many components on the same page call verifySession(), the actual decrypt-and-check logic only runs once per request — every subsequent call reuses the result. session.sub is the standard claim set by .setSubject() earlier, so this reads it back out under a friendlier userId name for the rest of your app. Call this at the top of any Server Component, Server Action, or Route Handler that needs to know who's logged in.
// app/dashboard/page.tsx
import { verifySession } from "@/lib/jwt/dal";
export default async function DashboardPage() {
const { userId } = await verifySession();
return <h1>Welcome back, user {userId}</h1>;
}
Add an optimistic check in proxy.ts
In Next.js 16, middleware.ts was renamed to proxy.ts — same file convention, same idea. It's the first thing that runs before a request reaches a page, which makes it a good place for a fast, optimistic redirect. Full verification still happens in the DAL above — proxy.ts just avoids letting an obviously-unauthenticated request render a protected page at all.
// proxy.ts
import { NextRequest, NextResponse } from "next/server";
const protectedRoutes = ["/dashboard"];
const publicRoutes = ["/login", "/signup"];
export default function proxy(req: NextRequest) {
const path = req.nextUrl.pathname;
const isProtectedRoute = protectedRoutes.some((route) => path.startsWith(route));
const isPublicRoute = publicRoutes.includes(path);
// Optimistic check only — we just look for the cookie's presence.
// We deliberately don't verify the signature here; that happens
// in verifySession() inside the Data Access Layer.
const cookie = req.cookies.get("session")?.value;
if (isProtectedRoute && !cookie) {
return NextResponse.redirect(new URL("/login", req.nextUrl));
}
if (isPublicRoute && cookie) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
⚠️ Common Mistake: Treating
proxy.tsas your only security check. It's a UX nicety that avoids a flash of protected content — it is not a substitute for re-verifying the session inside every Server Action and Route Handler that actually touches data. A request can always be sent directly, bypassing whatever page it "should" have come from.
Step 3: Build Database-Backed Sessions
Now the other approach — a real session table your server checks on each request.
Create the sessions table
-- Works for Postgres/Supabase; adjust types for your database
create table sessions (
token_hash text primary key,
user_id uuid not null references auth.users(id) on delete cascade,
expires_at timestamptz not null,
created_at timestamptz not null default now()
);
create index idx_sessions_user_id on sessions (user_id);
What's happening here: notice the primary key is token_hash, not token. We never store the raw session token in the database — only its hash. This mirrors how you'd never store a plaintext password: if your database ever leaked, an attacker with the hashes still couldn't reconstruct a working cookie.
⚠️ Cleanup: expiring a session only stops it from being accepted — the
expires_at < now()check inverifyDbSessionbelow handles that. It does not delete the row. Left alone, this table grows forever, one row per login, most of them long past their expiry date. Run a scheduled cleanup — a Vercel Cron job hitting a small Route Handler once a day is enough for most apps:delete from sessions where expires_at < now();This is purely a housekeeping/performance concern (an unbounded table gets slower to index and back up over time), not a security one — an expired row is already useless for authentication either way.
Create the session on login
// lib/db-session/create.ts
import "server-only";
import { randomBytes, createHash } from "crypto";
import { cookies } from "next/headers";
import { db } from "@/lib/db";
function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
export async function createDbSession(userId: string) {
const token = randomBytes(32).toString("hex"); // goes to the browser
const tokenHash = hashToken(token); // goes to the database
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await db.session.create({
data: { tokenHash, userId, expiresAt },
});
const cookieStore = await cookies();
cookieStore.set("session", token, {
httpOnly: true,
secure: true,
sameSite: "lax",
expires: expiresAt,
path: "/",
});
}
What's happening here: randomBytes(32) generates a token with enough entropy that guessing it is computationally infeasible — this is the piece doing the actual security work, not the hashing. We hash it with SHA-256 purely so a database leak doesn't hand out working session tokens; the browser still holds the real, unhashed token in its cookie.
Verify the session (with caching, so it doesn't hit the database on every render)
// lib/db-session/dal.ts
import "server-only";
import { cache } from "react";
import { cookies } from "next/headers";
import { createHash } from "crypto";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
export const verifyDbSession = cache(async () => {
const cookieStore = await cookies();
const token = cookieStore.get("session")?.value;
if (!token) redirect("/login");
const tokenHash = hashToken(token);
const session = await db.session.findUnique({
where: { tokenHash },
include: { user: true },
});
if (!session || session.expiresAt < new Date()) {
redirect("/login");
}
return { isAuth: true, userId: session.userId, user: session.user };
});
What's happening here: just like the JWT version, cache() means one database lookup per request no matter how many components ask "who's logged in?" This is the piece that makes database sessions viable in practice — without it, a page with five components each checking auth would fire five identical queries.
Revoking a session — the whole point of this approach
// lib/db-session/delete.ts
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { createHash } from "crypto";
import { db } from "@/lib/db";
function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
export async function deleteDbSession() {
const cookieStore = await cookies();
const token = cookieStore.get("session")?.value;
if (token) {
await db.session.delete({ where: { tokenHash: hashToken(token) } }).catch(() => {});
}
cookieStore.delete("session");
redirect("/login");
}
// Force-logout every session for a user — e.g. after a password reset
export async function revokeAllSessionsForUser(userId: string) {
await db.session.deleteMany({ where: { userId } });
}
What's happening here: revokeAllSessionsForUser is the function you can't build with plain JWTs — one query, and every device that user is logged in on is instantly signed out, because the next request they make finds no matching row. This is the single biggest practical reason teams reach for database sessions over JWTs.
proxy.ts looks identical to the JWT version above — it's still just checking whether the session cookie exists, since that check never needed to know which auth strategy is behind it.
Session vs JWT: Side-by-Side
| Session (database) | JWT | |
|---|---|---|
| Where the data lives | Server-side (database row) | Inside the token itself |
| Cookie contents | Random, meaningless ID | Signed, self-contained payload |
| Instant revocation | ✅ Delete the row | ❌ Not without extra machinery |
| Force-logout everywhere | ✅ One query | ❌ Requires a blocklist or short expiry + refresh |
| Per-request cost | One DB lookup (cacheable per request) | No DB call — just signature verification |
| Works well at scale / multi-server | Needs a shared database (usually already true) | Naturally stateless, nothing to share |
| Payload can go stale | No — always reads current data | Yes — data inside is frozen until re-issued |
| Implementation complexity | Slightly more (schema, hashing, cleanup) | Slightly less (no schema needed) |
| Best for | Apps where instant revocation matters (banking, admin tools, anything security-sensitive) | High-traffic, distributed systems where avoiding a DB hit per request matters |
💡 Tip: "JWTs don't need a database" is true for verification, but most real apps still store something — a refresh-token table, a denylist, or at minimum a
passwordChangedAttimestamp checked against the token'siat(issued-at) claim. The stateless promise of JWTs is a little softer in practice than it sounds in theory.
A Closer Look: What "Horizontal Scaling" Actually Means Here
"Naturally stateless" in the table above is doing a lot of work, so let's unpack it. Say your app runs on five server instances behind a load balancer — a request from the same user could land on a different instance every time.
- With database sessions: whichever instance handles the request just needs to reach the same database everyone else does. In practice this is a non-issue for most Next.js apps, since you already have one shared database — every instance was already talking to it for everything else. The "scaling problem" people worry about here mostly doesn't exist unless you're deliberately running sessions in something instance-local, like in-memory storage (don't do that).
- With JWTs: verification needs nothing but the signing secret, which every instance already has as an environment variable. No instance has to coordinate with any other instance, or with a database, to answer "is this token valid?" That's genuinely useful once you're running at the edge — Cloudflare Workers, Vercel Edge Functions — across multiple regions, where a round trip to a single-region database could add real, noticeable latency to every request.
So the honest version: for a typical app with one shared Postgres/Supabase database, this isn't really a deciding factor — you're already "horizontally scaled" for sessions for free. It starts to matter once you're running truly distributed, multi-region, or edge-first infrastructure where a database round trip per request is a real, measured cost, not a hypothetical one.
Sliding (Rolling) Sessions
So far, every example here uses a fixed expiry — log in, get 7 days, and after 7 days you're logged out no matter how active you were. A sliding session (also called a rolling session) instead extends the expiry every time the user does something, so an active user effectively never gets logged out, while an idle one still expires on schedule.
-
With database sessions, this is simple: on every successful
verifyDbSession()call, update that row'sexpires_atto "now + 7 days" again.// Inside verifyDbSession, after confirming the session is valid await db.session.update({ where: { tokenHash }, data: { expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) }, });One extra write per (cached) verification — cheap, and the source of truth (the row) is already being touched anyway.
💡 Tip: In production it's common to only extend the session once every few hours instead of on every request to avoid unnecessary writes.
- With JWTs, this is more work: the
expclaim is baked into the signature at issue time — you can't quietly extend it without re-signing a brand-new token and setting a brand-new cookie on the response. Doable inside a Route Handler or Server Action (create a fresh token with a newexpiresAt, callcookies().set()again), but it means writing a cookie back on requests that otherwise wouldn't need one, which is easy to forget to wire up consistently across every route.
💡 Tip: If "stay logged in while active, expire after a week of silence" is a real requirement for your app, that alone is a reasonable tie-breaker toward database sessions — it's a one-line
updatethere versus meaningfully more plumbing with JWTs.
Can You Get Revocation With JWTs?
Yes, with a tradeoff. Two common patterns:
- Short expiry + refresh tokens. Issue an access token that expires in 10–15 minutes, plus a longer-lived refresh token stored in the database. To revoke, you delete the refresh token — the access token is still technically valid, but only for a few more minutes, which is an acceptable blast radius for most apps.
- A denylist. Keep a small, fast-to-check store (Redis is the usual choice) of token IDs (
jticlaims) that have been explicitly revoked. Every verification also checks this list. This reintroduces a lookup on every request — at that point, you've mostly reinvented database sessions with extra steps.
Neither of these makes JWTs behave exactly like sessions — they just narrow the gap. If instant, guaranteed revocation is a hard requirement (think: a compromised admin account), database sessions remove the problem entirely instead of managing around it.
Which One Should You Actually Use?
- Building a typical SaaS dashboard, internal tool, or content site? Database sessions. The DB hit is cheap with caching, and "log this user out right now" is a feature you'll eventually need, even if you don't think so today.
- Building something with genuinely massive, distributed, stateless traffic (think: an API consumed by thousands of independent clients, or infrastructure where adding a database round trip to every request is a real cost)? JWTs earn their keep here.
- Already using Auth.js or Clerk? You don't have to choose by hand — Auth.js exposes
session: { strategy: "jwt" | "database" }directly, and Clerk manages this decision for you behind its own infrastructure. - Not sure yet? Default to database sessions. It's the safer, more debuggable choice, and the performance cost is rarely the bottleneck people assume it'll be — cache the lookup with
cache()as shown above and it disappears from your profiler.
Frequently Asked Questions
Can I combine both approaches?
Yes — this is actually what "refresh tokens" are: a short-lived JWT for fast verification, backed by a database-stored refresh token for revocation. You get JWT-speed reads most of the time, with a real revocation point when you need one.
Is storing a JWT in a cookie the same as storing it in localStorage?
No, and the difference matters for security. An httpOnly cookie can't be read by client-side JavaScript, so it's protected from XSS attacks that steal tokens. localStorage is fully readable by any script running on your page — a single XSS vulnerability anywhere in your dependency tree can leak every token stored there. Always use httpOnly cookies for auth tokens in a Next.js app.
Does proxy.ts need to fully verify the session on every request?
No, and for JWT-based auth verifying in proxy.ts with jose is genuinely fine performance-wise since it needs no database call. The stronger rule is: never treat proxy.ts as your only check. Always re-verify inside the Server Action or Route Handler that actually reads or writes data — proxy.ts can be bypassed by hitting that endpoint directly.
Why hash the session token before storing it in the database?
The same reason you hash passwords — if your database is ever compromised (a leaked backup, a misconfigured access policy), the attacker gets a list of hashes they can't turn back into working session cookies. It costs you one sha256 call per request, which is effectively free.
Which one does Auth.js use by default?
Auth.js defaults to the "jwt" strategy unless you configure a database adapter, in which case it switches to "database" automatically. You can also set session: { strategy: "jwt" } explicitly even with an adapter configured, if you want JWTs for speed while still persisting user accounts.
Wrapping Up
Sessions and JWTs aren't competing standards — they're two different answers to "where does the source of truth for a logged-in user live?" Sessions keep it on your server, trading a database lookup for instant, total control over who stays logged in. JWTs keep it in the token itself, trading that control for verification that never has to leave the request.
For most Next.js apps, start with database sessions — the caching pattern above keeps the performance cost near zero, and you'll be glad revocation is a solved problem the first time you actually need it. Reach for JWTs specifically when you've measured that the database hit matters, not because a tutorial defaulted to it.
From here, a natural next step is wiring either pattern into a real login form with Zod validating the credentials before they ever reach your session logic, or layering role-based permissions on top of whichever session strategy you pick.
Continue Learning
- How to Add Auth.js (NextAuth v5) Authentication in Next.js 16 (2026)
- How to Add Clerk Authentication in Next.js 16 (2026 Guide)
- Clerk vs NextAuth (Auth.js) in 2026: Which One Should You Choose?
- Role-Based Access Control (RBAC) in Next.js 16 (2026 Beginner's Guide)
- Supabase Auth vs Firebase Auth: Which One Should You Choose in 2026?
Useful Resources
More in Authentication
View AllSupabase Auth vs Firebase Auth: Which One Should You Choose in 2026?
Supabase Auth vs Firebase Auth compared for 2026 — setup, pricing, Next.js App Router integration, security, and which one fits your project.
Role-Based Access Control (RBAC) in Next.js 16 (2026 Beginner's Guide)
Learn how to implement role-based authorization (RBAC) in Next.js 16 App Router with TypeScript, Auth.js v5, Proxy, Server Actions, Route Handlers, and permissions.
How to Add Auth.js (NextAuth v5) Authentication in Next.js 16 (2026)
Learn how to add Auth.js (NextAuth v5) authentication to a Next.js 16 App Router project step by step — providers, sessions, protected routes, and proxy.ts, explained for beginners.
Clerk vs NextAuth (Auth.js) in 2026: Which One Should You Choose?
Compare Clerk vs Auth.js (NextAuth) v5 in 2026 for Next.js 16. Setup time, pricing, middleware security, customization, and which one fits your project.