Auth (JWT, sessions)
Auth in Node falls into three buckets: cookie + session (server-side), JWT (stateless tokens), and OAuth/OIDC (third-party). Pick by the client (web vs mobile vs API), then implement the smallest possible surface that supports your real flows: sign in, refresh, logout-everywhere, password reset, MFA.
Cookie + JWT + OIDC patterns in Node
EXAMPLE
// npm i express argon2 jose cookie-parser zod express-rate-limit
import express from 'express';
import argon2 from 'argon2';
import * as jose from 'jose';
import cookieParser from 'cookie-parser';
import rateLimit from 'express-rate-limit';
import { z } from 'zod';
const app = express();
app.use(express.json({ limit: '16kb' }));
app.use(cookieParser());
// 1) Password hashing — Argon2id (preferred over bcrypt today)
async function hashPassword(p: string) {
return argon2.hash(p, { type: argon2.argon2id, memoryCost: 64 * 1024, timeCost: 3, parallelism: 4 });
}
const users: Record<string, { hash: string; id: string; mfa?: string }> = {};
// 2) Sign up — rate-limited
app.post('/signup',
rateLimit({ windowMs: 60_000, max: 10 }),
async (req, res) => {
const body = z.object({ email: z.string().email(), password: z.string().min(8) }).parse(req.body);
if (users[body.email]) return res.status(409).json({ error: 'exists' });
users[body.email] = { id: crypto.randomUUID(), hash: await hashPassword(body.password) };
res.status(201).json({ id: users[body.email].id });
},
);
// 3) JWT signing key (in production, load from a managed KMS; rotate via JWKS)
const jwtKey = await jose.generateKeyPair('RS256');
async function signAccess(uid: string) {
return new jose.SignJWT({ uid })
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt()
.setIssuer('https://api.example.com')
.setAudience('shop-api')
.setExpirationTime('15m')
.sign(jwtKey.privateKey);
}
async function signRefresh(uid: string) {
return new jose.SignJWT({ uid, typ: 'refresh' })
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt()
.setIssuer('https://api.example.com')
.setAudience('shop-api')
.setExpirationTime('30d')
.sign(jwtKey.privateKey);
}
// 4) Login — constant-time compare via argon2.verify
app.post('/login',
rateLimit({ windowMs: 60_000, max: 20 }),
async (req, res) => {
const body = z.object({ email: z.string().email(), password: z.string() }).parse(req.body);
const u = users[body.email];
// Always run verify — equal time even if user does not exist
const stub = '$argon2id$v=19$m=65536,t=3,p=4$AAAA$AAAA';
const ok = await argon2.verify(u?.hash ?? stub, body.password).catch(() => false);
if (!u || !ok) return res.status(401).json({ error: 'wrong credentials' });
const access = await signAccess(u.id);
const refresh = await signRefresh(u.id);
res.cookie('refresh', refresh, {
httpOnly: true, secure: true, sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000, path: '/refresh',
});
res.json({ accessToken: access });
},
);
// 5) Refresh — short-lived access from long-lived cookie
app.post('/refresh', async (req, res) => {
const refresh = req.cookies.refresh;
if (!refresh) return res.status(401).end();
try {
const { payload } = await jose.jwtVerify(refresh, jwtKey.publicKey, {
issuer: 'https://api.example.com', audience: 'shop-api',
});
if ((payload as any).typ !== 'refresh') return res.status(401).end();
const access = await signAccess(payload.uid as string);
res.json({ accessToken: access });
} catch { res.status(401).end(); }
});
// 6) Logout-everywhere — bump a 'tokenVersion' on the user; verify with it
// (Persist per-user version in your DB; include in JWT; reject if mismatch.)
// 7) Protect routes
async function requireUser(req: any, res: any, next: any) {
const h = req.headers.authorization;
if (!h?.startsWith('Bearer ')) return res.status(401).end();
try {
const { payload } = await jose.jwtVerify(h.slice(7), jwtKey.publicKey,
{ issuer: 'https://api.example.com', audience: 'shop-api' });
req.user = { id: payload.uid };
next();
} catch { res.status(401).end(); }
}
app.get('/me', requireUser, (req: any, res) => res.json({ id: req.user.id }));
// 8) OIDC / OAuth — use passport-openidconnect or @panva/oidc-client
// Skeleton: redirect to issuer authorize endpoint with PKCE, on callback
// validate the id_token (jose.jwtVerify against issuer JWKS), create your
// own session OR sign your own JWT. Never trust the id_token in cookies
// without verification.
// 9) Cookie vs Bearer — pick by client
// Web app + same-origin -> HttpOnly cookie (refresh) + short JWT access
// Native app -> tokens in secure storage; bearer header
// 3rd-party API consumer -> client_credentials grant; bearer token
// Each has different CSRF + XSS implications; see csrf/* lessons
app.listen(3000);
Why it matters
Always pair short-lived access tokens (15 min) with long-lived refresh tokens stored in HttpOnly+Secure+SameSite cookies. Stolen access tokens expire fast; stolen refresh tokens are bound to the cookie and the IP/device fingerprint your refresh endpoint logs. Token leaks become 15-minute incidents instead of weeks of account takeover.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
import jwt from 'jsonwebtoken';
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { expiresIn: '1d' });
Try it Yourself »
Discussion
Loading…