Authentication & Authorization
Security-critical patterns for AuthN/AuthZ in 2026. Code here is meant to be copied, so it is written to be correct and safe by default: every secret stored hashed, every token rotation atomic, every redirect-based flow CSRF-protected via state/PKCE. Vendor endpoints and library APIs drift — when a value here is dated, the inline note tells you where to re-verify.
Threat-model defaults: assume the browser is hostile (XSS can read anything JS can), assume tokens leak, assume requests are replayed and races happen. Prefer short-lived access tokens + server-held session/refresh state. For SPAs, prefer a BFF (Backend-for-Frontend) holding tokens server-side over putting access tokens in localStorage.
1. OAuth 2.1 / OIDC Flows
OAuth 2.1 (the consolidation of 2.0 + best-practice RFCs) makes PKCE mandatory for all clients, forbids the implicit and password grants, and requires exact redirect-URI matching. Use the Authorization Code flow + PKCE everywhere (yes, even confidential server-side clients).
Discover endpoints (don't hardcode)
Prefer the provider's OIDC discovery document over hardcoded URLs so endpoints and the JWKS URI stay correct:
// Fetch once at boot, cache in memory (respect Cache-Control)
const discovery = await fetch(
'https://accounts.google.com/.well-known/openid-configuration'
).then(r => r.json());
// => { authorization_endpoint, token_endpoint, jwks_uri, issuer, ... }
// Google (as of Jun 2026): authorization_endpoint = https://accounts.google.com/o/oauth2/v2/auth
// token_endpoint = https://oauth2.googleapis.com/token
// Verify: https://accounts.google.com/.well-known/openid-configuration
Authorization Code + PKCE — full server-side flow with state
This is the canonical flow. state and the PKCE code_verifier are both persisted server-side before redirect and verified on callback — skipping either reopens CSRF / login-injection / code-injection. Below, secrets live on the server and only code_challenge + state ever hit the browser.
import crypto from 'node:crypto';
const b64url = (buf) => buf.toString('base64url'); // Node >=16 supports 'base64url'
function pkcePair() {
const verifier = b64url(crypto.randomBytes(32)); // 43-128 chars, high entropy
const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
return { verifier, challenge };
}
// --- Step 1: begin login (server route) ---
app.get('/auth/login', async (req, res) => {
const state = b64url(crypto.randomBytes(32));
const nonce = b64url(crypto.randomBytes(32)); // OIDC: binds id_token to this session
const { verifier, challenge } = pkcePair();
// PERSIST state + verifier + nonce server-side, keyed to THIS session, BEFORE redirecting.
// Short TTL; single use. (Express-session shown; a signed httpOnly cookie also works.)
req.session.oauth = { state, nonce, verifier, createdAt: Date.now() };
const url = new URL(discovery.authorization_endpoint);
url.searchParams.set('client_id', process.env.OAUTH_CLIENT_ID);
url.searchParams.set('redirect_uri', process.env.OAUTH_REDIRECT_URI); // must EXACTLY match registered URI
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', 'openid email profile');
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('state', state);
url.searchParams.set('nonce', nonce);
res.redirect(url.toString());
});
// --- Step 2: callback (server route) — VALIDATE everything ---
app.get('/auth/callback', async (req, res) => {
const { code, state } = req.query;
const saved = req.session.oauth;
delete req.session.oauth; // consume immediately so it can't be replayed
// (a) provider returned an error?
if (req.query.error) return res.status(400).send(`OAuth error: ${req.query.error}`);
// (b) we actually started a flow, and it hasn't expired
if (!saved || Date.now() - saved.createdAt > 10 * 60 * 1000) {
return res.status(400).send('No pending OAuth flow / expired');
}
// (c) STATE MUST MATCH — constant-time compare to avoid timing oracles
const ok = typeof state === 'string'
&& state.length === saved.state.length
&& crypto.timingSafeEqual(Buffer.from(state), Buffer.from(saved.state));
if (!ok) return res.status(403).send('Invalid OAuth state'); // CSRF / login-injection blocked here
// (d) need a code
if (typeof code !== 'string' || !code) return res.status(400).send('Missing authorization code');
// (e) exchange code — token request is form-encoded; include the PKCE verifier we persisted
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: process.env.OAUTH_REDIRECT_URI,
client_id: process.env.OAUTH_CLIENT_ID,
code_verifier: saved.verifier, // proves we started this exact flow
});
// Confidential clients add their secret (Basic auth header preferred over body params):
const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
if (process.env.OAUTH_CLIENT_SECRET) {
const basic = Buffer.from(
`${process.env.OAUTH_CLIENT_ID}:${process.env.OAUTH_CLIENT_SECRET}`
).toString('base64');
headers.Authorization = `Basic ${basic}`;
}
const tokenRes = await fetch(discovery.token_endpoint, { method: 'POST', headers, body });
if (!tokenRes.ok) return res.status(401).send('Token exchange failed');
const tokens = await tokenRes.json(); // { access_token, refresh_token, id_token, expires_in }
// (f) Validate the OIDC id_token signature/iss/aud/exp AND that nonce matches saved.nonce
// (see §2 verifyToken; pass audience = OAUTH_CLIENT_ID and check decoded.nonce === saved.nonce)
// (g) Establish your OWN session here (don't hand provider tokens to the browser).
res.redirect('/');
});
Authorization Code + PKCE — public client (SPA/mobile) caveat
A SPA cannot keep state/verifier truly secret from XSS. sessionStorage survives a redirect but is JS-readable. Preferred 2026 pattern: run the code exchange in a BFF so the browser never holds tokens. If you must do it browser-side, still generate and check state, and still send the code_verifier:
// Browser: begin
const { verifier, challenge } = await pkcePairWebCrypto(); // Web Crypto version below
const state = crypto.randomUUID();
sessionStorage.setItem('pkce_verifier', verifier);
sessionStorage.setItem('oauth_state', state);
const url = new URL(discovery.authorization_endpoint);
url.searchParams.set('client_id', CLIENT_ID);
url.searchParams.set('redirect_uri', REDIRECT_URI);
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', 'openid email profile');
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('state', state);
location.href = url.toString();
// Browser: callback — VALIDATE state before exchanging
const params = new URLSearchParams(location.search);
const code = params.get('code');
const returnedState = params.get('state');
const savedState = sessionStorage.getItem('oauth_state');
const verifier = sessionStorage.getItem('pkce_verifier');
sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('pkce_verifier');
if (!code || !returnedState || returnedState !== savedState) {
throw new Error('Invalid OAuth state or missing code'); // stop — do not exchange
}
const res = await fetch(discovery.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code', code,
client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, code_verifier: verifier,
}),
});
const tokens = await res.json();
// Web Crypto PKCE (browser)
async function pkcePairWebCrypto() {
const bytes = crypto.getRandomValues(new Uint8Array(32));
const verifier = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
return { verifier, challenge };
}
Client Credentials Flow (Machine-to-Machine)
For backend services and API-to-API calls. No user. Token requests are form-encoded per RFC 6749, the secret stays server-side, and you should cache the token until shortly before expires_in rather than minting one per call.
let cached = { token: null, exp: 0 };
async function getServiceToken() {
if (cached.token && Date.now() < cached.exp - 60_000) return cached.token; // 60s safety margin
const res = await fetch(process.env.OAUTH_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET, // server-only; never ship to a browser
audience: 'https://api.example.com', // provider-specific (Auth0 uses `audience`)
scope: 'read:things write:things',
}),
});
if (!res.ok) throw new Error(`Token request failed: ${res.status}`);
const { access_token, expires_in } = await res.json();
cached = { token: access_token, exp: Date.now() + expires_in * 1000 };
return access_token;
}
2. JWT (JSON Web Tokens)
Structure
header.payload.signature
Header: { "alg": "RS256", "typ": "JWT", "kid": "key-id-1" }
Payload: { "iss": "https://auth.example.com", "aud": "https://api.example.com",
"sub": "user123", "role": "admin", "iat": 1706000000, "exp": 1706003600 }
Signature: RS256(base64url(header) + "." + base64url(payload), privateKey)
JWT Validation (Node.js) — pin algorithm, issuer, audience
The most common JWT bugs: accepting alg: none, accepting whatever alg the token claims (HS256/RS256 confusion), or not checking iss/aud. Always pin algorithms explicitly, and always validate issuer and audience. Cache JWKS keys (jwks-rsa caches + rate-limits by default).
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
const client = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
cache: true, cacheMaxEntries: 5, cacheMaxAge: 10 * 60 * 1000, // 10 min
rateLimit: true, jwksRequestsPerMinute: 10,
});
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => callback(err, key?.getPublicKey()));
}
function verifyToken(token) {
return new Promise((resolve, reject) => {
jwt.verify(token, getKey, {
algorithms: ['RS256'], // pin; never allow 'none' or HS*/RS* mixing
issuer: 'https://auth.example.com', // must match token iss
audience: 'https://api.example.com', // must match token aud (your API/client id)
clockTolerance: 5, // seconds, for minor clock skew
}, (err, decoded) => (err ? reject(err) : resolve(decoded)));
});
}
// Express middleware
async function authMiddleware(req, res, next) {
const token = req.headers.authorization?.replace(/^Bearer /, '');
if (!token) return res.status(401).json({ error: 'No token provided' });
try {
req.user = await verifyToken(token);
next();
} catch {
return res.status(401).json({ error: 'Invalid token' });
}
}
jsonwebtoken is the workhorse; for ESM/edge runtimes consider jose (jwtVerify + createRemoteJWKSet), which is promise-native and runs on Web Crypto.
Refresh Token Rotation — hashed, atomic, reuse-detecting
Refresh tokens are long-lived bearer credentials, so treat them like passwords:
- Store only a hash (SHA-256 is fine for a high-entropy random token; you don't need bcrypt/argon2 for 256-bit randomness). The raw token exists only in the client's secure cookie.
- Look up by hash, never by raw value.
- Rotate atomically in a DB transaction with a compare-and-set so two concurrent refreshes can't both succeed.
- Detect reuse: a refresh token is single-use. If a used/rotated token is presented again, treat it as theft and revoke the whole token family.
import crypto from 'node:crypto';
const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
const newOpaqueToken = () => crypto.randomBytes(32).toString('base64url'); // 256-bit, unguessable
// Issue at login: create a family, store HASH, return RAW token to client (httpOnly cookie)
async function issueRefreshToken(userId, meta) {
const raw = newOpaqueToken();
const familyId = crypto.randomUUID();
await db.refreshToken.create({ data: {
tokenHash: sha256(raw), userId, familyId,
used: false, revoked: false,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
userAgent: meta?.userAgent, ip: meta?.ip, // device/session metadata
}});
return raw; // caller sets it as a Secure; HttpOnly; SameSite cookie (path=/auth/refresh)
}
app.post('/auth/refresh', async (req, res) => {
const raw = req.cookies?.refresh_token; // delivered via httpOnly cookie, not body
if (!raw) return res.status(401).json({ error: 'No refresh token' });
const tokenHash = sha256(raw);
try {
const result = await db.$transaction(async (tx) => {
// Lock the row (Postgres) so concurrent refreshes serialize on it.
const [stored] = await tx.$queryRaw`
SELECT * FROM "RefreshToken" WHERE "tokenHash" = ${tokenHash} FOR UPDATE`;
if (!stored) throw { code: 'INVALID' };
// REUSE DETECTION: a used or revoked token presented again => credential theft.
if (stored.used || stored.revoked) {
await tx.refreshToken.updateMany({
where: { familyId: stored.familyId },
data: { revoked: true }, // nuke the entire family
});
throw { code: 'REUSE' };
}
if (stored.expiresAt < new Date()) throw { code: 'EXPIRED' };
// Atomic compare-and-set: only the first concurrent caller flips used:false -> true.
const claim = await tx.refreshToken.updateMany({
where: { id: stored.id, used: false },
data: { used: true },
});
if (claim.count !== 1) throw { code: 'RACE' }; // someone else won; reject this one
// Mint the next token in the SAME family and store its hash.
const nextRaw = newOpaqueToken();
await tx.refreshToken.create({ data: {
tokenHash: sha256(nextRaw), userId: stored.userId, familyId: stored.familyId,
used: false, revoked: false,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
userAgent: req.get('user-agent'), ip: req.ip,
}});
const user = await tx.user.findUnique({ where: { id: stored.userId } });
const accessToken = jwt.sign(
{ sub: stored.userId, role: user.role },
process.env.JWT_PRIVATE_KEY,
{ algorithm: 'RS256', issuer: 'https://auth.example.com',
audience: 'https://api.example.com', expiresIn: '15m' }
);
return { accessToken, nextRaw };
});
res.cookie('refresh_token', result.nextRaw, {
httpOnly: true, secure: true, sameSite: 'strict', path: '/auth/refresh',
maxAge: 30 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken: result.accessToken });
} catch (e) {
if (e?.code === 'REUSE') {
// Optional: log/audit + force user re-auth on all devices.
return res.status(401).json({ error: 'Token reuse detected; family revoked' });
}
return res.status(401).json({ error: 'Invalid refresh token' });
}
});
The
FOR UPDATErow lock +updateMany(where: { used: false })compare-and-set is what makes this safe under concurrency. On databases withoutSELECT ... FOR UPDATE, rely solely on the conditional update's affected-row count (claim.count === 1) as the gate — never on a read-then-write without it.
Token lifetimes:
- Access token: 15 minutes (short-lived, stateless, RS256)
- Refresh token: 30 days max, rotated on every use, hashed at rest
- ID token: ~1 hour (OIDC user info; validate
nonceon login)
3. Session Management
Cookie-Based Sessions (Traditional / BFF)
import session from 'express-session';
import { RedisStore } from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
app.set('trust proxy', 1); // required behind a TLS-terminating proxy so secure cookies work
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET, // rotate via an array: [newSecret, oldSecret]
resave: false,
saveUninitialized: false,
name: '__Host-session', // __Host- prefix: requires Secure, path=/, no Domain
cookie: {
secure: true, // HTTPS only
httpOnly: true, // no JS access (XSS can't read it)
sameSite: 'lax', // mitigates cross-site POST CSRF (not a complete defense — see §10)
maxAge: 24 * 60 * 60 * 1000,
path: '/',
// NOTE: __Host- forbids `domain`. Drop the prefix if you need a shared parent domain.
},
}));
// Regenerate the session ID on privilege change to prevent session fixation:
app.post('/auth/login', loginLimiter, async (req, res) => {
// ... verify credentials ...
req.session.regenerate((err) => {
if (err) return res.status(500).end();
req.session.userId = user.id;
res.json({ ok: true });
});
});
Cookie vs Token Comparison
| Aspect | Cookie Sessions | JWT Access Tokens |
|---|---|---|
| Storage | Server (Redis/DB) | Client — see XSS row |
| Stateless | No (server lookup) | Yes (self-contained) |
| Revocation | Easy (delete from store) | Hard (need blocklist or short TTL) |
| Scalability | Need shared store | No shared state needed |
| XSS exposure | httpOnly cookie is unreadable by JS | Avoid localStorage (JS-readable → XSS steals it). Keep in memory, or use a BFF that holds the token server-side. |
| CSRF exposure | Needs CSRF defense (§10) | Safe only if sent in Authorization header, not a cookie |
| Mobile | Needs cookie support | Works everywhere |
| Best for | Server-rendered apps, BFF for SPAs | Native/mobile, service-to-service |
2026 browser recommendation: do not store access tokens in localStorage/sessionStorage. Either (a) use a BFF where the browser holds only an httpOnly session cookie and the server attaches tokens to upstream calls, or (b) keep the access token in a JS variable in memory and refresh via an httpOnly cookie hitting /auth/refresh.
4. Auth.js v5 (NextAuth) Setup — App Router
Auth.js v5 splits config into a root auth.ts (exports handlers, auth, signIn, signOut) and a thin route handler. Provider imports are now next-auth/providers/* named like Google, GitHub, Credentials (the old GoogleProvider names are v4). Env vars are auto-inferred from AUTH_<PROVIDER>_ID/SECRET; set AUTH_SECRET (generate with npx auth secret) and AUTH_TRUST_HOST=true when self-hosting behind a proxy.
// auth.ts (project root)
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';
import GitHub from 'next-auth/providers/github';
import Credentials from 'next-auth/providers/credentials';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/lib/prisma';
import argon2 from 'argon2';
import { z } from 'zod';
const signInSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: 'jwt' }, // 'jwt' for Credentials; 'database' for OAuth-only is also fine
providers: [
Google, // reads AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET
GitHub, // reads AUTH_GITHUB_ID / AUTH_GITHUB_SECRET
Credentials({
credentials: { email: {}, password: {} },
authorize: async (credentials) => {
const parsed = signInSchema.safeParse(credentials);
if (!parsed.success) return null;
const user = await prisma.user.findUnique({ where: { email: parsed.data.email } });
// Same generic outcome whether the user is missing or the password is wrong (no enumeration):
if (!user?.hashedPassword) return null;
const ok = await argon2.verify(user.hashedPassword, parsed.data.password);
return ok ? user : null;
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) { token.role = user.role; token.id = user.id; }
return token;
},
async session({ session, token }) {
session.user.role = token.role as string;
session.user.id = token.id as string;
return session;
},
},
pages: { signIn: '/login', error: '/auth/error' },
});
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';
export const { GET, POST } = handlers;
// types: augment the session/JWT so token.role/id type-check
// types/next-auth.d.ts
import 'next-auth';
declare module 'next-auth' {
interface User { role?: string }
interface Session { user: { id: string; role?: string } & DefaultSession['user'] }
}
Read the session in a Server Component/route via const session = await auth();. Protect routes in middleware.ts by exporting auth as the middleware.
5. Passport.js Strategies
Invalid email vs Invalid password are different responses, which lets an attacker enumerate accounts. Return one generic message and run the password compare even when the user is missing (constant-ish work) so timing doesn't leak existence either.
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { Strategy as LocalStrategy } from 'passport-local';
import argon2 from 'argon2';
// A precomputed dummy argon2 hash so we do equivalent work when the user doesn't exist.
const DUMMY_HASH = process.env.DUMMY_ARGON2_HASH; // generate once: argon2.hash('not-a-real-password')
passport.use(new LocalStrategy(
{ usernameField: 'email' },
async (email, password, done) => {
const user = await db.findUserByEmail(String(email).trim().toLowerCase());
const hash = user?.hashedPassword ?? DUMMY_HASH;
const valid = await argon2.verify(hash, password).catch(() => false);
if (!user || !valid) {
return done(null, false, { message: 'Invalid credentials' }); // generic; no enumeration
}
return done(null, user);
}
));
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: '/auth/google/callback',
}, async (accessToken, refreshToken, profile, done) => {
const email = profile.emails?.[0]?.value;
let user = await db.findUserByGoogleId(profile.id);
if (!user) {
user = await db.createUser({ googleId: profile.id, email, name: profile.displayName });
}
return done(null, user);
}));
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser(async (id, done) => done(null, await db.findUserById(id)));
Also add account lockout / progressive delay after repeated failures (see §10 rate limiting) and audit-log auth events (login success/failure, password change, MFA enrollment) with user id, IP, and timestamp.
6. Passkeys / WebAuthn (@simplewebauthn/server v13)
The v13 API differs from older snippets you'll find online:
generateRegistrationOptionstakesuserName/userDisplayName— there is nouserIDoption you pass a string to anymore.- After registration, persist
registrationInfo.credential={ id, publicKey, counter, transports }(pluscredentialDeviceType/credentialBackedUp).idis a Base64URL string;publicKeyis aUint8Array(store as bytes/bytea). verifyAuthenticationResponsetakes a singlecredentialobject (the oldauthenticator:key is gone) and arequireUserVerificationboolean.- Always store and re-send
transports, and write backnewCounterto detect cloned authenticators.
import {
generateRegistrationOptions, verifyRegistrationResponse,
generateAuthenticationOptions, verifyAuthenticationResponse,
} from '@simplewebauthn/server';
const rpName = 'My App';
const rpID = 'example.com'; // domain only, no scheme/port
const origin = 'https://example.com'; // full origin the browser sends
// --- Registration: options ---
app.post('/auth/passkey/register/options', async (req, res) => {
const user = req.user;
const existing = await db.getCredentialsByUserId(user.id);
const options = await generateRegistrationOptions({
rpName, rpID,
userName: user.email,
userDisplayName: user.name ?? user.email,
attestationType: 'none',
excludeCredentials: existing.map(c => ({ id: c.credentialId, transports: c.transports })),
authenticatorSelection: {
residentKey: 'preferred', // 'required' for usernameless/discoverable login
userVerification: 'preferred', // 'required' to force biometric/PIN
},
supportedAlgorithmIDs: [-7, -257], // ES256, RS256
});
await db.saveChallenge(user.id, options.challenge); // store server-side, short TTL
res.json(options);
});
// --- Registration: verify ---
app.post('/auth/passkey/register/verify', async (req, res) => {
const user = req.user;
const expectedChallenge = await db.getChallenge(user.id);
let verification;
try {
verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: true,
});
} catch (err) {
return res.status(400).json({ error: err.message });
}
if (verification.verified && verification.registrationInfo) {
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
await db.saveCredential(user.id, {
credentialId: credential.id, // Base64URLString
publicKey: credential.publicKey, // Uint8Array -> store as bytes
counter: credential.counter,
transports: credential.transports, // e.g. ['internal','hybrid']
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
});
}
await db.clearChallenge(user.id);
res.json({ verified: verification.verified });
});
// --- Authentication: options ---
app.post('/auth/passkey/login/options', async (req, res) => {
// Optionally scope to a known user's credentials; omit allowCredentials for usernameless flow.
const options = await generateAuthenticationOptions({
rpID,
userVerification: 'preferred',
// allowCredentials: creds.map(c => ({ id: c.credentialId, transports: c.transports })),
});
await db.saveSessionChallenge(req.sessionID, options.challenge);
res.json(options);
});
// --- Authentication: verify ---
app.post('/auth/passkey/login/verify', async (req, res) => {
const expectedChallenge = await db.getSessionChallenge(req.sessionID);
const stored = await db.getCredentialById(req.body.id); // req.body.id is Base64URLString
if (!stored) return res.status(400).json({ error: 'Unknown credential' });
let verification;
try {
verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: { // v13 shape (was `authenticator`)
id: stored.credentialId,
publicKey: stored.publicKey, // Uint8Array
counter: stored.counter,
transports: stored.transports,
},
requireUserVerification: true,
});
} catch (err) {
return res.status(400).json({ error: err.message });
}
if (verification.verified) {
// CRITICAL: persist newCounter to detect cloned authenticators / replay.
await db.updateCounter(stored.id, verification.authenticationInfo.newCounter);
await db.clearSessionChallenge(req.sessionID);
req.login(stored.user, () => res.json({ verified: true }));
} else {
res.status(401).json({ verified: false });
}
});
Pair with @simplewebauthn/browser (startRegistration/startAuthentication) on the client. Verify the current API at https://simplewebauthn.dev (this skill targets v13).
7. RBAC & ABAC
Role-Based Access Control (RBAC)
const PERMISSIONS = {
admin: ['read', 'write', 'delete', 'manage_users', 'manage_billing'],
editor: ['read', 'write'],
viewer: ['read'],
} as const;
type Role = keyof typeof PERMISSIONS;
type Permission = (typeof PERMISSIONS)[Role][number];
function requirePermission(permission: Permission) {
return (req, res, next) => {
const userRole = req.user?.role as Role | undefined;
const perms = (userRole && PERMISSIONS[userRole]) || [];
if (!perms.includes(permission)) return res.status(403).json({ error: 'Forbidden' });
next();
};
}
app.delete('/api/posts/:id', requirePermission('delete'), deletePost);
app.get('/api/posts', requirePermission('read'), listPosts);
Attribute-Based Access Control (ABAC)
interface PolicyContext {
user: { id: string; role: string; department: string };
resource: { ownerId: string; type: string; status: string; department?: string };
action: string;
}
function evaluatePolicy(ctx: PolicyContext): boolean {
if (ctx.user.role === 'admin') return true;
// Owners can edit their own resources
if (ctx.action === 'edit' && ctx.resource.ownerId === ctx.user.id) return true;
// Editors can edit published resources in their own department
if (ctx.action === 'edit' && ctx.user.role === 'editor'
&& ctx.resource.status === 'published'
&& ctx.resource.department === ctx.user.department) return true;
return false; // default-deny
}
Default-deny is the rule: if no policy explicitly allows the action, reject. Always enforce authorization server-side per request — never trust a client-sent role/permission, and re-check ownership on every mutating route (most IDOR bugs are a missing per-object check).
8. Password Hashing
Use a memory-hard algorithm. argon2id is the OWASP first choice; bcrypt is an acceptable, widely-supported fallback (note bcrypt silently truncates inputs beyond 72 bytes — pre-hash with SHA-256 if you must allow long passphrases). Never roll your own.
import argon2 from 'argon2';
import bcrypt from 'bcryptjs';
// argon2id (recommended) — OWASP 2026 baseline params
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB (OWASP min); raise to 46–64 MiB on capable servers
timeCost: 2, // iterations
parallelism: 1,
});
const valid = await argon2.verify(hash, password);
// bcrypt fallback (cost >=12 in 2026)
const bhash = await bcrypt.hash(password, 12);
const bvalid = await bcrypt.compare(password, bhash);
Never: MD5, SHA-1, plain SHA-256/512 (fast → brute-forceable), or any unsalted/un-stretched hash for passwords. Check new passwords against a breached-password list (e.g., HIBP k-anonymity range API) and enforce a sane minimum length over arbitrary complexity rules (NIST SP 800-63B).
9. MFA / 2FA with TOTP
Three correctness rules this implements:
- Never return the TOTP secret to the client as a "backup code." The secret is the authenticator seed; if it's also a "backup code" then anyone who saw setup can mint valid TOTP codes forever. Backup codes are separate, random, single-use, stored hashed.
- Verify enrollment before activating, and tolerate one time-step of clock drift (
epochTolerance: 30, one 30-second step; otplib v12 called thiswindow: 1). - Bind the login MFA step to a pending first-factor session — never trust a
userIdfrom the request body, or anyone can complete MFA "as" any user id.
import { generateSecret, generateURI, verify } from 'otplib';
import QRCode from 'qrcode';
import crypto from 'node:crypto';
const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
// --- Setup: generate secret + QR. Do NOT return the secret as a backup code. ---
app.post('/auth/mfa/setup', requireAuth, async (req, res) => {
const secret = generateSecret();
const otpauth = generateURI({ issuer: 'MyApp', label: req.user.email, secret });
const qrCode = await QRCode.toDataURL(otpauth);
await db.saveTempMfaSecret(req.user.id, secret); // pending; not yet active
// Return ONLY the QR/otpauth so the user can scan it. The raw secret is shown once for
// manual entry only if you choose to; it is NOT a backup code.
res.json({ qrCode, otpauth });
});
// --- Verify enrollment + issue SEPARATE single-use backup codes (stored hashed) ---
app.post('/auth/mfa/verify', requireAuth, async (req, res) => {
const { token } = req.body;
const secret = await db.getTempMfaSecret(req.user.id);
if (!secret || !(await verify({ token, secret, epochTolerance: 30 })).valid) {
return res.status(400).json({ error: 'Invalid code' });
}
await db.activateMfa(req.user.id, secret);
// Backup codes: random, single-use, displayed ONCE, stored only as hashes.
const plain = Array.from({ length: 10 }, () => crypto.randomBytes(5).toString('hex')); // 10×10-hex
await db.replaceBackupCodes(req.user.id, plain.map(c => sha256(c))); // store HASHES only
res.json({ success: true, backupCodes: plain }); // show once; never retrievable again
});
// --- Login MFA challenge: bind to a pending first-factor session, NOT a body userId ---
app.post('/auth/mfa/challenge', async (req, res) => {
const { token } = req.body;
// mfaPending was set by the password/first-factor step after it verified credentials.
const userId = req.session?.mfaPending?.userId;
if (!userId) return res.status(401).json({ error: 'No pending login' });
const user = await db.findUserById(userId);
let ok = (await verify({ token, secret: user.mfaSecret, epochTolerance: 30 })).valid;
if (!ok) {
// Backup code path: look up by HASH and consume single-use.
ok = await db.consumeBackupCode(userId, sha256(String(token)));
}
if (!ok) return res.status(401).json({ error: 'Invalid MFA code' });
// First + second factor both satisfied → now establish the authenticated session.
delete req.session.mfaPending;
req.session.regenerate((err) => {
if (err) return res.status(500).end();
req.session.userId = userId;
res.json({ accessToken: issueAccessToken(user) });
});
});
// The first-factor handler that sets mfaPending (sketch):
// after verifying email+password, if user.mfaEnabled:
// req.session.mfaPending = { userId: user.id, at: Date.now() }; // short TTL
// return res.json({ mfaRequired: true });
This targets otplib v13 (async, returns { valid }); code written for the v12 authenticator API should pin otplib@^12.
Rate-limit /auth/mfa/challenge per pending session (e.g., 5 attempts) and expire mfaPending after a few minutes. For recovery, require re-verification (email link + cooldown) before disabling MFA, and re-issue fresh backup codes when the user regenerates them.
10. Security Best Practices
CSRF Protection (do NOT use csurf)
csurf has been deprecated/unmaintained since 2022 — don't add it to new code. Use SameSite cookies as a baseline plus an explicit token defense, and validate Origin/Referer on state-changing requests.
// Option A (recommended for sessions): double-submit signed token via `csrf-csrf`
import { doubleCsrf } from 'csrf-csrf';
const { generateCsrfToken, doubleCsrfProtection } = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET,
getSessionIdentifier: (req) => req.session?.id ?? '', // REQUIRED: binds the token to this session
cookieName: '__Host-csrf',
cookieOptions: { sameSite: 'strict', secure: true, path: '/', httpOnly: true },
getCsrfTokenFromRequest: (req) => req.headers['x-csrf-token'],
});
app.get('/csrf-token', (req, res) => res.json({ csrfToken: generateCsrfToken(req, res) }));
app.use(doubleCsrfProtection); // rejects unsafe methods without a matching token
// Option B (defense in depth): reject state-changing requests from foreign origins
app.use((req, res, next) => {
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
const origin = req.get('origin') || req.get('referer') || '';
const allowed = ['https://app.example.com'];
if (!allowed.some(a => origin.startsWith(a))) {
return res.status(403).json({ error: 'Cross-origin request blocked' });
}
}
next();
});
For SPAs/mobile using JWT in the Authorization header (not cookies), CSRF tokens aren't required — the browser won't auto-attach a header cross-site. But if you store any auth state in cookies (including a BFF session), you DO need CSRF defense. SameSite=Lax/Strict reduces risk but is not complete: it doesn't cover same-site subdomain attacks, and Lax still allows top-level cross-site GETs — so never perform state changes on GET.
Secure Cookie Configuration
res.cookie('__Host-session', token, {
httpOnly: true, // JS can't read it (XSS mitigation)
secure: true, // HTTPS only
sameSite: 'strict', // 'lax' only if you need top-level cross-site navigation to stay logged in
maxAge: 86_400_000, // 24h
path: '/',
// __Host- prefix => browser enforces Secure + path=/ + NO Domain (locks cookie to exact host)
});
Rate Limiting Login Attempts
Throttle on IP and account, normalized, not just one. The default IP key generator mishandles IPv6 (a whole /64 shares an address) — use express-rate-limit's ipKeyGenerator helper. Combine a coarse per-IP limit with a stricter per-account limit, and add account lockout/backoff for repeated failures.
import rateLimit, { ipKeyGenerator } from 'express-rate-limit';
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 attempts per key per window
standardHeaders: 'draft-7',
legacyHeaders: false,
message: { error: 'Too many login attempts. Try again later.' },
// Key by account + IP. Normalize email; ipKeyGenerator() handles IPv6 correctly.
keyGenerator: (req) => {
const email = String(req.body?.email ?? '').trim().toLowerCase();
return `${email}|${ipKeyGenerator(req.ip)}`;
},
});
app.post('/auth/login', loginLimiter, loginHandler);
For distributed deployments back the limiter with a shared store (e.g. rate-limit-redis) so limits are global, not per-instance. Track consecutive failures per account and apply exponential backoff or temporary lockout, with an audit log entry per failure.
Social Login Setup (Google, GitHub, Apple)
Required env vars per provider (verify scopes/console layout at each link; layouts change):
| Provider | Vars | Console |
|---|---|---|
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | console.cloud.google.com → APIs & Services → Credentials | |
| GitHub | GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET | github.com/settings/developers → OAuth Apps |
| Apple | APPLE_CLIENT_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_PRIVATE_KEY | developer.apple.com → Certificates, IDs & Profiles |
(With Auth.js v5, prefer the auto-inferred AUTH_GOOGLE_ID/AUTH_GOOGLE_SECRET form.)
Callback URLs: register the exact callback URL(s); no wildcards in production. Keep all client secrets and Apple private keys server-side only — never expose them to the browser bundle.
Secure logout / session invalidation
- Server sessions: destroy the session record (
req.session.destroy) and clear the cookie. Don't rely on the client to "forget" the cookie. - JWTs: keep them short-lived; on logout, delete/revoke the refresh-token family (§2) and, for high-value apps, add the access token's
jtito a short-TTL blocklist until it expires. - On password change or detected compromise, revoke all of the user's refresh-token families and active sessions.