iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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…