Two months ago a client paged me on a Saturday because their JWT authentication setup was leaking sessions. Their tutorial-grade implementation handed out 30-day access tokens, stored them in localStorage, and had no way to log a user out. A leaked token from a phishing campaign on Monday was still valid the following Wednesday — and the client had no kill switch.
That is the failure mode this article exists to prevent. JWT authentication in Node.js is not hard. Building it the way most tutorials show you is dangerous. The version below — short-lived access tokens, opaque refresh tokens stored hashed in PostgreSQL, refresh-token rotation with reuse detection, and a real logout that actually revokes — is what I ship for paying clients on Node 20 LTS, Express 5, and TypeScript. You can copy it, harden it further, and sleep on Friday nights.
Quick start: a working login in 60 lines
If you just need a copy-paste baseline before reading the production sections, this is it. Three endpoints, in-memory user, no database, HS256 access tokens. Run it, hit it with curl, then read the rest of the article to harden it.
npm i express jsonwebtoken bcrypt
npm i -D typescript @types/express @types/jsonwebtoken @types/bcrypt tsx// quickstart.ts
import express from 'express';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
const ACCESS_SECRET = 'change-me-in-prod';
const app = express();
app.use(express.json());
const users = [
{ id: '1', email: 'demo@nodewire.net', hash: bcrypt.hashSync('hunter2', 12) },
];
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = users.find(u => u.email === email);
if (!user || !(await bcrypt.compare(password, user.hash))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const accessToken = jwt.sign({ sub: user.id }, ACCESS_SECRET, {
algorithm: 'HS256',
expiresIn: '15m',
});
res.json({ accessToken });
});
app.get('/me', (req, res) => {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) return res.sendStatus(401);
try {
const payload = jwt.verify(header.slice(7), ACCESS_SECRET, {
algorithms: ['HS256'],
});
res.json({ user: payload });
} catch {
res.sendStatus(401);
}
});
app.listen(3000, () => console.log('http://localhost:3000'));npx tsx quickstart.ts
curl -X POST http://localhost:3000/auth/login \
-H 'content-type: application/json' \
-d '{"email":"demo@nodewire.net","password":"hunter2"}'That works. It is also wrong for production for at least eight reasons, all of which the rest of this article fixes.
What is wrong with the typical JWT tutorial
Most JWT tutorials show you jwt.sign(payload, secret), hand back a token that lives 24 hours or 30 days, tell you to put it in localStorage, and end. That implementation has four production failures the moment you ship it:
- You cannot log a user out. Stateless JWTs are valid until they expire. Logout that only deletes the client copy is theatre.
- A leaked token gives a 30-day breach window. Long-lived access tokens are scanned for in every public GitHub leak, every malicious browser extension, every XSS payload.
localStorageis XSS-readable. One injected script on any page of your domain exfiltrates the token to an attacker server.- You will eventually rotate the signing secret. Without a key-id (
kid) header and a key registry, every active session breaks at once.
The auth flow at a glance
Before the code, the moving parts. Read this once and the routes below stop feeling like spaghetti:
Browser Express API PostgreSQL
------- ----------- ----------
POST /auth/login ---> verify password
sign access JWT (15m)
generate refresh (opaque)
INSERT refresh_token ---> hash, familyId, expires
<--- access JWT + Set-Cookie
GET /api/things ---> verify access JWT
<--- 200 OK
(15m later: access expires)
POST /auth/refresh ---> read cookie
SHA-256 lookup ---> match row
mark old as revoked
INSERT new refresh ---> same familyId
sign new access JWT
<--- access JWT + new cookie
POST /auth/logout ---> revoke current row ---> revokedAt = now()
<--- Set-Cookie clearedTwo storage rules fall out of this diagram and never change:
- Access token: short, signed JWT, lives in browser memory only.
- Refresh token: long, opaque random string, lives in an
httpOnlycookie and as a hash in your database.
Setup: dependencies and project structure
Same packages as the quick start plus Prisma, cookie-parser, and a validator. Skip Prisma if you are on Drizzle or raw pg — the schema below maps cleanly to either.
npm i express cookie-parser jsonwebtoken bcrypt zod @prisma/client
npm i -D typescript @types/express @types/cookie-parser @types/jsonwebtoken @types/bcrypt prisma tsxProject structure I use on every backend:
src/
auth/
tokens.ts # sign + verify access JWT, generate refresh
cookies.ts # Set-Cookie helpers for the refresh cookie
middleware.ts # requireAuth() — Bearer token verification
routes.ts # /auth/login, /auth/refresh, /auth/logout
db/
prisma.ts # singleton PrismaClient
env.ts # zod-validated environment variables
server.ts # app wiring
prisma/
schema.prismaEnvironment validation is non-negotiable for auth code. Validate .env with Zod at boot so a missing JWT_ACCESS_SECRET crashes the process before it ever signs a token. The first time a typo silently turns your secret into the literal string undefined, you will agree.
Two secrets, both 32 bytes of entropy, never the same value:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Prisma schema for the refresh-token store
The whole security model collapses if your refresh-token table is wrong, so this is the part to copy carefully. If Prisma is new to you, start here — this article assumes npx prisma init already ran.
// prisma/schema.prisma
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
createdAt DateTime @default(now())
refreshTokens RefreshToken[]
}
model RefreshToken {
id String @id @default(cuid())
userId String
familyId String // shared across rotated descendants
hash String @unique // SHA-256 of the raw token
expiresAt DateTime
revokedAt DateTime?
replacedBy String? // hash of the token that rotated this one
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([familyId])
@@index([expiresAt])
}Three fields do the heavy lifting. familyId ties every rotated descendant of one login back to a single root, which makes reuse detection a one-line query. revokedAt is the kill switch for logout and for password changes. expiresAt lets you delete dead rows nightly with a simple WHERE expiresAt < now() cron — the table never grows unbounded.
Run the migration:
npx prisma migrate dev --name auth_initToken helpers: signing, verifying, hashing
Three functions live in auth/tokens.ts. The whole point is that nothing else in the codebase ever calls jwt.sign or jwt.verify directly — those primitives leak too easily.
// src/auth/tokens.ts
import jwt, { SignOptions } from 'jsonwebtoken';
import { randomBytes, createHash } from 'crypto';
import { env } from '../env';
const ACCESS_TTL: SignOptions['expiresIn'] = '15m';
const REFRESH_TTL_DAYS = 30;
export function signAccessToken(userId: string) {
return jwt.sign({ sub: userId }, env.JWT_ACCESS_SECRET, {
algorithm: 'HS256',
expiresIn: ACCESS_TTL,
issuer: 'nodewire',
audience: 'nodewire-web',
});
}
export function verifyAccessToken(token: string): { sub: string } {
return jwt.verify(token, env.JWT_ACCESS_SECRET, {
algorithms: ['HS256'], // pin algorithm — never trust the header
issuer: 'nodewire',
audience: 'nodewire-web',
}) as { sub: string };
}
export function generateRefreshToken() {
const raw = randomBytes(64).toString('base64url');
const hash = createHash('sha256').update(raw).digest('hex');
const expiresAt = new Date(Date.now() + REFRESH_TTL_DAYS * 24 * 60 * 60 * 1000);
return { raw, hash, expiresAt };
}
export function hashRefreshToken(raw: string) {
return createHash('sha256').update(raw).digest('hex');
}Two design choices that auditors flag if you skip them:
- Pin
algorithms: ['HS256']on everyverifycall. Without it the RFC 7519alg: nonedowngrade attack is back on the menu. - Refresh tokens are opaque and SHA-256 hashed before they touch the database. Bcrypt would be slower with no security gain — refresh tokens are 64 bytes of CSPRNG output, not user-chosen passwords. SHA-256 is the right primitive here. Bcrypt belongs on password hashing, not on tokens.
Login route: password check, both tokens, cookie
The login handler does four things and stops: verify password, sign access JWT, mint a refresh token, send the cookie.
// src/auth/routes.ts
import { Router } from 'express';
import bcrypt from 'bcrypt';
import { z } from 'zod';
import { db } from '../db/prisma';
import {
signAccessToken,
generateRefreshToken,
hashRefreshToken,
} from './tokens';
import { setRefreshCookie, clearRefreshCookie } from './cookies';
const LoginInput = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export const auth = Router();
auth.post('/login', async (req, res) => {
const { email, password } = LoginInput.parse(req.body);
const user = await db.user.findUnique({ where: { email } });
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const refresh = generateRefreshToken();
const familyId = crypto.randomUUID();
await db.refreshToken.create({
data: {
userId: user.id,
familyId,
hash: refresh.hash,
expiresAt: refresh.expiresAt,
},
});
setRefreshCookie(res, refresh.raw);
res.json({ accessToken: signAccessToken(user.id) });
});The cookie helper is short and worth seeing in full — every flag matters:
// src/auth/cookies.ts
import { Response } from 'express';
const COOKIE_NAME = 'nw_rt';
export function setRefreshCookie(res: Response, raw: string) {
res.cookie(COOKIE_NAME, raw, {
httpOnly: true, // JS cannot read it
secure: true, // HTTPS only (set false locally if needed)
sameSite: 'lax', // prevents the obvious CSRF cases
path: '/auth', // sent only to the auth namespace
maxAge: 30 * 24 * 60 * 60 * 1000,
});
}
export function clearRefreshCookie(res: Response) {
res.clearCookie(COOKIE_NAME, { path: '/auth' });
}Scoping path to /auth means your business endpoints never receive the refresh cookie. Smaller blast radius if a downstream middleware logs request headers.
Refresh route: rotation and reuse detection
Refresh is where most tutorials cut corners. Three rules, all enforced in the handler below: rotate on every use, mark the old token revoked, and if a revoked token is presented again, kill the entire family.
auth.post('/refresh', async (req, res) => {
const raw = req.cookies?.nw_rt;
if (!raw) return res.status(401).json({ error: 'No refresh token' });
const hash = hashRefreshToken(raw);
const stored = await db.refreshToken.findUnique({ where: { hash } });
if (!stored || stored.expiresAt < new Date()) {
clearRefreshCookie(res);
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Reuse detected: someone presented a token that was already rotated.
// Revoke the entire family — this user has been compromised.
if (stored.revokedAt) {
await db.refreshToken.updateMany({
where: { familyId: stored.familyId, revokedAt: null },
data: { revokedAt: new Date() },
});
clearRefreshCookie(res);
return res.status(401).json({ error: 'Token reuse detected' });
}
const next = generateRefreshToken();
await db.$transaction([
db.refreshToken.update({
where: { id: stored.id },
data: { revokedAt: new Date(), replacedBy: next.hash },
}),
db.refreshToken.create({
data: {
userId: stored.userId,
familyId: stored.familyId, // family persists across rotation
hash: next.hash,
expiresAt: next.expiresAt,
},
}),
]);
setRefreshCookie(res, next.raw);
res.json({ accessToken: signAccessToken(stored.userId) });
});The reuse-detection branch is what makes this implementation production-grade. If an attacker steals a refresh token from a victim’s machine and uses it once, the legitimate user’s next refresh attempt presents the now-revoked token, the family gets killed, and both attacker and victim are forced to log in again. The user is mildly annoyed; the attacker is locked out. That trade is correct.
Logout route: the kill switch
Logout is one query plus a cookie clear. If you only do the cookie clear, the refresh token stays valid in the database and an attacker who already has it can keep rotating.
auth.post('/logout', async (req, res) => {
const raw = req.cookies?.nw_rt;
if (raw) {
const hash = hashRefreshToken(raw);
await db.refreshToken.updateMany({
where: { hash, revokedAt: null },
data: { revokedAt: new Date() },
});
}
clearRefreshCookie(res);
res.status(204).end();
});“Log out everywhere” is one extra line — revoke every non-revoked refresh token where userId = sub. Same query, different filter.
Middleware: protecting routes
// src/auth/middleware.ts
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken } from './tokens';
declare global {
namespace Express { interface Request { userId?: string } }
}
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing bearer token' });
}
try {
const { sub } = verifyAccessToken(header.slice(7));
req.userId = sub;
next();
} catch {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}Mount it once and forget:
app.get('/api/things', requireAuth, (req, res) => {
res.json({ ok: true, userId: req.userId });
});Client-side: storing the access token and auto-refreshing
The browser side is fifteen lines and the part most articles skip. Access token lives in a module-level variable — never localStorage, never sessionStorage, never a cookie you can read. The refresh cookie is sent automatically by the browser because we set credentials: 'include'.
// client/auth.ts
let accessToken: string | null = null;
export async function login(email: string, password: string) {
const res = await fetch('/auth/login', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error('Login failed');
({ accessToken } = await res.json());
}
export async function authFetch(input: RequestInfo, init: RequestInit = {}) {
const headers = new Headers(init.headers);
if (accessToken) headers.set('authorization', `Bearer ${accessToken}`);
let res = await fetch(input, { ...init, headers, credentials: 'include' });
if (res.status === 401 && accessToken) {
const r = await fetch('/auth/refresh', { method: 'POST', credentials: 'include' });
if (!r.ok) { accessToken = null; throw new Error('Session expired'); }
({ accessToken } = await r.json());
headers.set('authorization', `Bearer ${accessToken}`);
res = await fetch(input, { ...init, headers, credentials: 'include' });
}
return res;
}
export async function logout() {
await fetch('/auth/logout', { method: 'POST', credentials: 'include' });
accessToken = null;
}Page reload loses the in-memory access token. That is fine — authFetch will get a 401 on the first protected call, the silent /auth/refresh succeeds because the cookie is still in the browser, and the user never sees the dance.
Where each piece lives, at a glance
| Token | Lifetime | Where it lives | How it dies |
|---|---|---|---|
| Access JWT | 15 minutes | Browser memory only | Expires; tab close also clears it |
| Refresh token (raw) | 30 days | httpOnly Secure cookie, path /auth |
Logout / rotation / reuse detection |
| Refresh token (SHA-256) | 30 days | PostgreSQL, indexed on hash |
revokedAt set, then nightly cleanup of expired rows |
Production checklist
Print this. Tape it next to your monitor. Every box has to tick before auth code goes anywhere near a real user:
- Access token TTL is 10–15 minutes. Anything longer and logout becomes a polite request.
- Refresh token is opaque, generated by
crypto.randomBytes(64), hashed in the database. Never raw, never bcrypt. - Refresh cookie has
httpOnly,Secure,SameSite=Lax, and a scopedpath. - Refresh token rotates on every use. Old token gets
revokedAtin the same transaction. - Reuse detection revokes the whole
familyId. Both attacker and user are kicked. - Logout writes to the database, not just the cookie.
- Password change revokes every refresh token for that user.
jwt.verifypinsalgorithms: ['HS256'](or['RS256']if you went asymmetric).- Secrets come from validated env vars, not from a hard-coded fallback string.
- Nightly cron deletes refresh rows where
expiresAt < now().
When not to use JWT authentication at all
JWT pays off when you have multiple services that need to verify identity without calling a session store. For everything else, server sessions are simpler and harder to mess up. Reach for express-session with a Redis store and skip this entire article when:
- You have one Express monolith and one frontend, both on the same domain.
- You need instant revocation everywhere (sessions kill on store delete; JWTs need rotation windows).
- Your team has shipped exactly zero production auth systems before. Sessions are forgiving; JWT mistakes are silent.
I have migrated three clients off premature JWT setups back to sessions and saved them a load of incidents in the process. Pick the boring tool until you can name the specific reason JWT helps.
Troubleshooting FAQ
How long should a JWT access token live in Node.js?
10 to 15 minutes. Short enough that a leaked token expires before most attack chains complete; long enough that /auth/refresh traffic stays cheap. Anything over an hour is hard to defend in a security review.
Can I store a JWT or refresh token in localStorage?
No. localStorage is readable by any script that runs on your origin, including injected ones. Access tokens go in JavaScript memory; refresh tokens go in httpOnly cookies. Every senior engineer who ships JWT this way agrees on this point — there is no controversy here.
Do I need Redis for JWT revocation?
Not if you implement refresh-token rotation against PostgreSQL like the schema in this article. Redis is useful when you also want to revoke individual access tokens (a denylist of jti claims), but with 15-minute access tokens and a fast refresh path, the marginal value is small. Add Redis when you have a concrete reason — for example, a compliance requirement to revoke active sessions inside a 60-second SLA.
HS256 or RS256 for Node.js JWT?
HS256 (symmetric, one shared secret) for a single backend that signs and verifies its own tokens. RS256 (asymmetric, private/public keypair) when other services have to verify your tokens but should not be able to mint them — federated APIs, microservices, mobile SDKs. RS256 is also the right default if you are building anything OAuth-shaped.
How do I implement “log out everywhere”?
One line: db.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: new Date() } }). Every active session loses its refresh token; every access token expires within fifteen minutes; the user is fully out.
What about CSRF if the refresh cookie auto-sends?
SameSite=Lax blocks the standard CSRF vector for cross-origin POSTs. If your refresh endpoint must accept cross-site requests (subdomain split, separate frontend host), drop to SameSite=None; Secure and add a CSRF double-submit token on the refresh route specifically.
Users get 401 immediately after rotation — what broke?
Race condition in the SPA: two tabs both hit /auth/refresh simultaneously, the second one presents the now-rotated token and triggers reuse detection. Fix on the client by serialising refresh calls behind a single in-flight Promise; one shared refreshing: Promise<void> | null in the auth module is enough.
How do I rotate the JWT signing secret without logging everyone out?
Add a kid header to issued tokens, keep a small registry of { kid → secret }, and have verify pick the secret by kid. Issue new tokens with the new kid; retire the old secret after the access-token TTL window plus a margin. Honest answer: most teams skip this and accept a forced re-login during rotation, which is fine if you do it during low-traffic windows.
Does this work with Fastify or NestJS?
Same primitives, different glue. If you are picking between Express and Fastify, the choice is mostly about middleware ergonomics — the auth model in this article is identical on either runtime.
What ships next
This article covers the auth core. The next two pieces I am writing for nodewire build directly on this foundation: Helmet plus per-route rate limiting for login and refresh endpoints (for stopping credential stuffing), and a step-by-step guide for moving an existing session-based Express app to JWT without forced logouts during the migration window. If you want them as soon as they ship, the email box at the bottom of any post is the only signup form on the site.