Node.js NotesRohit’s interview study guide
Chapter 08

Authentication: JWT, Sessions & OAuth

Who you are vs what you may do — JWT access tokens, refresh-token rotation, sessions, cookies, OAuth 2.0 with PKCE, and Passport.

15 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Authentication vs authorization

Two questions get asked on every protected request, and they are different questions. Authentication asks "who are you?" — it turns a credential (a password, a session cookie, a signed token) into an identity like "user 42". Authorization asks "may this identity do this action on this resource?" — can user 42 read the orders of user 100? Delete this invoice? See this tenant's data?

A useful mental model is an airport. Passport control is authentication: it establishes that you are Rohit. Your boarding pass is authorization: it lets you onto one flight, in one seat — not into the cockpit, and not onto every plane at the gate. Being identified is necessary, but it never grants anything by itself.

A related source of confusion is format vs purpose. "JWT" names a token format; "access token" names a token's job. A JWT can be an access token, an ID token, or something else entirely, and an access token can also be an opaque random string. It's like "PDF" vs "invoice": one is the file format, the other is what the document is for. And OAuth is an authorization framework, not a token format at all.

In Express, the two questions usually become two middleware functions: one that establishes req.user (or answers 401), and one per route or resource that checks permission (or answers 403).

authn-authz.cjs
const express = require('express');
const jwt = require('jsonwebtoken');
const request = require('supertest');

const JWT_SECRET = 'demo-secret-not-for-production';
const app = express();

// Authentication: who is calling? Sets req.user or answers 401.
function authenticate(req, res, next) {
  const [scheme, token] = (req.get('authorization') ?? '').split(' ');
  if (scheme !== 'Bearer' || !token) {
    return res.status(401).set('WWW-Authenticate', 'Bearer').json({ error: 'missing token' });
  }
  try {
    const claims = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'], issuer: 'study-auth', audience: 'orders-api' });
    req.user = { id: claims.sub, role: claims.role };
    next();
  } catch (err) {
    res.status(401).set('WWW-Authenticate', 'Bearer error="invalid_token"').json({ error: 'invalid token' });
  }
}

// Authorization: may THIS user do THIS action on THIS resource? Answers 403 if not.
function canReadOrders(req, res, next) {
  if (req.user.id === req.params.userId || req.user.role === 'admin') return next();
  res.status(403).json({ error: 'not your orders' });
}

app.get('/users/:userId/orders', authenticate, canReadOrders, (req, res) => {
  res.json({ userId: req.params.userId, orders: ['#1001', '#1002'] });
});

const tokenFor = (sub, role) =>
  jwt.sign({ sub, role }, JWT_SECRET, { expiresIn: '15m', issuer: 'study-auth', audience: 'orders-api' });

(async () => {
  const show = (label, r) => console.log(label.padEnd(22), r.status, JSON.stringify(r.body), r.headers['www-authenticate'] ?? '');
  show('no token', await request(app).get('/users/42/orders'));
  show('garbage token', await request(app).get('/users/42/orders').set('Authorization', 'Bearer abc.def.ghi'));
  show('user 42 → own', await request(app).get('/users/42/orders').auth(tokenFor('42', 'user'), { type: 'bearer' }));
  show('user 42 → user 100', await request(app).get('/users/100/orders').auth(tokenFor('42', 'user'), { type: 'bearer' }));
  show('admin → user 100', await request(app).get('/users/100/orders').auth(tokenFor('7', 'admin'), { type: 'bearer' }));
})();

// no token               401 {"error":"missing token"} Bearer
// garbage token          401 {"error":"invalid token"} Bearer error="invalid_token"
// user 42 → own          200 {"userId":"42","orders":["#1001","#1002"]}
// user 42 → user 100     403 {"error":"not your orders"}
// admin → user 100       200 {"userId":"100","orders":["#1001","#1002"]}
What’s happening
  1. The route lists two middleware functions before the handler: authenticate, then canReadOrders. Express runs them in order, and each either calls next() to continue or sends a response to stop the chain (see Middleware and the request pipeline).
  2. No token: the Authorization header is missing, so scheme is '' and authenticate answers 401 with WWW-Authenticate: Bearer — the standard way to tell the client "this resource needs a Bearer credential". canReadOrders never runs.
  3. Garbage token: abc.def.ghi has three parts but the first one isn't valid JSON once decoded, so jwt.verify throws (invalid token) and we answer 401 again. The client's identity is unknown, so it's an authentication failure. We return a fixed message rather than err.message so we don't hand attackers verification details.
  4. User 42 asking for their own orders: verification succeeds, req.user becomes { id: '42', role: 'user' }, and canReadOrders sees req.user.id === req.params.userId ('42' === '42', both strings — sub is always a string) → next() → 200.
  5. User 42 asking for user 100's orders: the token is perfectly valid — authentication passes — but the ownership check fails and the role isn't admin, so we answer 403. The server knows exactly who you are; you just aren't allowed.
  6. Admin: same check, but the role branch lets them through. This is the whole point: a valid token proves identity; only an explicit rule on the server grants access to a specific resource.
AdvancedReference: 401 vs 403
StatusMeaningTypical cause
401 Unauthorized"I don't know who you are" — authentication missing or failedNo token, expired token, bad signature. Should carry WWW-Authenticate.
403 Forbidden"I know who you are, and the answer is no"Valid token, but wrong owner, role, tenant or scope. Re-authenticating won't help.
404 Not FoundSometimes used instead of 403 on purposeHides whether user 100's invoice exists at all.
AdvancedPitfall: missing ownership checks
Which status?

A request arrives with a valid, unexpired access token for user 42 and calls DELETE /users/100. Only admins may delete other users. The token's role claim is user. What should the API answer, and why not the other code?

Show answer

403 Forbidden.

What’s happening
  1. Authentication succeeds: the signature, issuer, audience and expiry all check out, so the server knows the caller is user 42. A 401 would be wrong — it tells the client "get a (new) credential", and a fresh token for user 42 would be refused just the same.
  2. Authorization fails: the rule is "admins only", and the token's role is user, so the action is refused → 403.
  3. Some APIs would answer 404 instead, to avoid confirming that user 100 exists. That's a deliberate design choice, not a different meaning.

#How a JWT is built

A JSON Web Token is three pieces of text joined by dots: header.payload.signature. The header and payload are JSON objects, encoded with base64url (the URL-safe variant of base64: - and _ instead of + and /, and no = padding) so the whole token can travel in a header, a cookie or a URL. The signature is computed over the first two segments with a key.

The mental model: a JWT is a transparent envelope with a tamper-evident seal. Anyone holding it can read everything inside — the payload is encoded, not encrypted. But nobody can change a single character without breaking the seal, and only someone with the key can produce a new valid seal. A signed JWT gives you integrity and authenticity, not confidentiality.

You don't need a library to build one. HS256 means "HMAC with SHA-256", which Node's crypto module has built in:

build-jwt.cjs
const { createHmac } = require('node:crypto');
const jwt = require('jsonwebtoken');

const secret = 'demo-secret-not-for-production';
const b64url = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');

const header = { alg: 'HS256', typ: 'JWT' };
const payload = { sub: '42', role: 'user', iat: 1780000000, exp: 1780000900 };

const signingInput = `${b64url(header)}.${b64url(payload)}`;
const signature = createHmac('sha256', secret).update(signingInput).digest('base64url');
const handMade = `${signingInput}.${signature}`;

const fromLibrary = jwt.sign(payload, secret); // HS256 is the default
console.log(handMade);
console.log(handMade === fromLibrary);

// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNzgwMDAwMDAwLCJleHAiOjE3ODAwMDA5MDB9.4Mlq7LtwuLkzsA2ZnudJAgu3aXirCHAHUw1Cm0oFA1g
// true
What’s happening
  1. The header {"alg":"HS256","typ":"JWT"} is stringified and base64url-encoded → eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. Every JWT you've seen starts with eyJ because {" always encodes to eyJ.
  2. The payload is encoded the same way → eyJzdWIiOiI0MiIs…. The times are seconds since 1970 (not milliseconds): iat 1780000000 is 2026-05-28T20:26:40Z and exp is 900 seconds later, 20:41:40Z.
  3. The signing input is the two encoded segments joined with a dot. The signature is the HMAC-SHA256 of that exact string under the secret → 32 bytes, which base64url-encode to 43 characters: 4Mlq7Ltw…FA1g.
  4. jwt.sign(payload, secret) produces a byte-for-byte identical token. Since payload already contains iat, the library uses it instead of the current time; with no options, the algorithm defaults to HS256 and the header is { alg, typ } in that order.
  5. Key point: the signature covers the encoded text, not the "meaning" of the JSON. Change one character of the header or payload segment — even reorder keys — and the signature no longer matches.
AdvancedDecoding a JWT without a key

Decoding is the reverse, and it needs no key at all:

decode-jwt.cjs
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNzgwMDAwMDAwLCJleHAiOjE3ODAwMDA5MDB9.4Mlq7LtwuLkzsA2ZnudJAgu3aXirCHAHUw1Cm0oFA1g';

const [h, p, s] = token.split('.');
console.log(Buffer.from(h, 'base64url').toString());             // {"alg":"HS256","typ":"JWT"}
console.log(JSON.parse(Buffer.from(p, 'base64url').toString())); // { sub: '42', role: 'user', iat: 1780000000, exp: 1780000900 }
console.log(Buffer.from(s, 'base64url').length, 'signature bytes'); // 32 signature bytes
What’s happening
  1. split('.') gives the three segments. There are always exactly three for a signed JWT (the compact JWS form); an encrypted JWE has five.
  2. Buffer.from(h, 'base64url') turns the 36 characters of the header back into 27 bytes of UTF-8 JSON. Node's 'base64url' encoding handles the missing padding and the -/_ alphabet for you.
  3. The payload decodes to the claims object — readable by anyone who sees the token: the browser, a proxy log, a support engineer pasting it into a website.
  4. The signature isn't JSON; it's 32 raw bytes (the size of a SHA-256 output). You can't "read" it — you can only check it by recomputing it with the key, which is what verification does.
AdvancedWhy base64url, not base64

Why base64url rather than plain base64? Plain base64 uses +, / and =, which all mean something in URLs and cookies:

JavaScript
const s = JSON.stringify({ q: '???' });
Buffer.from(s).toString('base64');    // 'eyJxIjoiPz8/In0='
Buffer.from(s).toString('base64url'); // 'eyJxIjoiPz8_In0'
AdvancedReference: registered JWT claims

The same bytes, but / became _ and the trailing = was dropped, so the token can sit in a query string without escaping.

The payload's fields are called claims. Seven names are registered by the JWT spec; everything else (like role) is application-specific:

ClaimNameWhat it meansChecked by jwt.verify when…
issIssuerWho created the token, e.g. https://auth.example.comyou pass issuer
subSubjectWhom it's about — a stable user ID, always a stringyou pass subject
audAudienceWhich API (or client) it's meant foryou pass audience
expExpirationSeconds since epoch; invalid at or after this timealways, if present
nbfNot beforeInvalid before this timealways, if present
iatIssued atWhen it was createdonly with maxAge
jtiJWT IDUnique ID, useful for denylists and replay detectionyou pass jwtid

Keep claims minimal: an ID, maybe a role or scopes, and the registered claims. Never a password, never anything you wouldn't print in a log.

AdvancedCorrecting my notes: signatures and secrecy
Read this token

Without any library or key, what does this payload segment say, and how long is the token valid?

Output
eyJzdWIiOiI0MiIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNzgwMDAwMDAwLCJleHAiOjE3ODAwMDA5MDB9
Show answer

It decodes to {"sub":"42","role":"user","iat":1780000000,"exp":1780000900} — user 42, role user, valid for 15 minutes.

What’s happening
  1. Buffer.from(segment, 'base64url').toString() gives the JSON text — no key needed, which is exactly why the payload must never contain secrets.
  2. exp - iat = 1780000900 - 1780000000 = 900 seconds = 15 minutes.
  3. Being able to read it tells you nothing about whether it's genuine. Anyone can write this JSON and encode it; only the signature (checked with the key) proves the issuer produced it.

#Signing and verifying JWTs

My notes describe the classic flow in four steps, and it's still the right skeleton:

  1. Login: the user sends credentials; the server checks them (see Hashing passwords with bcrypt) and creates a JWT with the user's ID, signed with a key.
  2. Token returned: the server sends the JWT back; the client stores it (see Where to store tokens in the browser).
  3. Requests: the client sends it on every request, usually as Authorization: Bearer <token> (or automatically, in a cookie).
  4. Verification: the API verifies the token and, if valid, treats the request as that user.

The part interviews probe is step 4. Verifying is not decoding. jwt.decode() just base64url-decodes the payload — it will happily "decode" a forged token. jwt.verify() does a fixed sequence of checks, and you must tell it what to expect: which algorithms are allowed, which issuer and audience are acceptable. Think of a bouncer checking an ID card: is the hologram real (signature), is it in date (exp, nbf), and is it a card for this club (aud, iss)?

In jsonwebtoken 9, verify checks, in this order (read from its source): the token has three parts and decodes → the header's alg is in your algorithms list → the key type matches the algorithm → signature → nbf → exp → aud → iss → sub → jti → nonce → maxAge. The first failure wins. Here is every common failure, with the real errors:

verify-jwt.cjs
const jwt = require('jsonwebtoken');

const secret = 'demo-secret-not-for-production';
const iat = 1780000000; // 2026-05-28T20:26:40Z — fixed so the tokens below never change

const token = jwt.sign({ sub: '42', role: 'user', iat }, secret, {
  algorithm: 'HS256',
  expiresIn: '15m',
  issuer: 'study-auth',
  audience: 'orders-api',
});

const expected = { algorithms: ['HS256'], issuer: 'study-auth', audience: 'orders-api' };

function check(label, tok, key, opts) {
  try {
    const claims = jwt.verify(tok, key, opts);
    console.log(label, '→ OK', claims);
  } catch (err) {
    console.log(label, '→', `${err.name}: ${err.message}`, err.expiredAt ?? '');
  }
}

// 1. Five minutes after issue: everything matches.
check('valid', token, secret, { ...expected, clockTimestamp: iat + 300 });

// 2. Attacker edits the payload to role "admin" and keeps the old signature.
const [h, p, s] = token.split('.');
const claims = JSON.parse(Buffer.from(p, 'base64url'));
const forged = `${h}.${Buffer.from(JSON.stringify({ ...claims, role: 'admin' })).toString('base64url')}.${s}`;
check('tampered', forged, secret, { ...expected, clockTimestamp: iat + 300 });

// 3. Sixteen minutes after issue.
check('expired', token, secret, { ...expected, clockTimestamp: iat + 960 });

// 4. A different API receives the token.
check('wrong aud', token, secret, { ...expected, audience: 'billing-api', clockTimestamp: iat + 300 });

// 5. Signed with a different secret.
check('wrong key', token, 'some-other-secret', { ...expected, clockTimestamp: iat + 300 });

// 6. alg "none": header says unsigned, signature segment is empty.
const none = `${Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url')}.${p}.`;
check('alg none', none, secret, { ...expected, clockTimestamp: iat + 300 });

// 7. Not a JWT at all.
check('garbage', 'not-a-token', secret, expected);
AdvancedOutput: verification results per token
Output
valid → OK {
  sub: '42',
  role: 'user',
  iat: 1780000000,
  exp: 1780000900,
  aud: 'orders-api',
  iss: 'study-auth'
}
tampered → JsonWebTokenError: invalid signature
expired → TokenExpiredError: jwt expired 2026-05-28T20:41:40.000Z
wrong aud → JsonWebTokenError: jwt audience invalid. expected: billing-api
wrong key → JsonWebTokenError: invalid signature
alg none → JsonWebTokenError: jwt signature is required
garbage → JsonWebTokenError: jwt malformed
What’s happening
  1. Signing: expiresIn: '15m' becomes exp = iat + 900; issuer and audience become the iss and aud claims. clockTimestamp is a real verify option that says "pretend now is this many seconds since epoch" — it lets the demo travel in time deterministically.
  2. Valid (now = iat + 300): format OK, HS256 is allowed, the recomputed HMAC matches, there's no nbf, iat + 300 < exp, and aud/iss match → the claims come back as a plain object.
  3. Tampered: the attacker re-encoded the payload with role: 'admin' and kept the original signature. Verify recomputes the HMAC over the new header.payload text, gets different bytes, and stops at the signature check → invalid signature. Without the secret, there is no way to produce the right signature for the edited payload.
  4. Expired (now = iat + 960): the signature is fine, then the exp check sees now >= exp and throws a TokenExpiredError whose expiredAt is the exp time as a Date. Clients typically react to this one by refreshing (see Access tokens and refresh tokens). It fails at exactly exp too — the comparison is >=.
  5. Wrong audience: everything up to exp passes, then the token's aud is orders-api but this verifier expects billing-api → rejected. This is what stops a token minted for one API being replayed against another that shares the same signing key.
  6. Wrong key fails at the signature, same message as tampering. alg: none is the famous attack where the header claims the token is unsigned; jsonwebtoken 9 refuses because a key was supplied but the signature segment is empty. Garbage fails the very first check (not three segments) → jwt malformed.
Which error wins?

Using the same token as above, what does jwt.verify throw if the token is both expired and tampered? And if it's expired and sent to the wrong audience?

Show answer

Expired + tampered → JsonWebTokenError: invalid signature. Expired + wrong audience → TokenExpiredError: jwt expired. (Both verified with jsonwebtoken 9.0.3.)

What’s happening
  1. Verification stops at the first failed check, and the order is signature → nbf → exp → aud → iss.
  2. Tampered: the signature check comes before any claim is trusted — reading exp from a payload whose signature is wrong would mean trusting attacker-written data — so you get invalid signature.
  3. Wrong audience: the signature is genuine, then exp is checked before aud, so you get jwt expired and never see the audience error.
  4. Practical consequence: don't build client logic on the exact message beyond TokenExpiredError vs everything else ("refresh" vs "log in again").
AdvancedHS256 vs RS256 and ES256

HS256 vs RS256 / ES256

With HS256, one shared secret both signs and verifies. That's fine when the same service issues and checks tokens, but every service that can verify can also mint tokens for any user. With asymmetric algorithms (RS256 = RSA, ES256 = ECDSA on the P-256 curve), the auth service keeps the private key and every API gets only the public key — it can verify but never sign.

es256.cjs
const { generateKeyPairSync, createHmac } = require('node:crypto');
const jwt = require('jsonwebtoken');

// The auth service owns the private key; every API gets only the public key.
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });

const token = jwt.sign({ sub: '42' }, privateKey, {
  algorithm: 'ES256', expiresIn: '15m', issuer: 'study-auth', audience: 'orders-api', keyid: 'key-2026-05',
});
console.log(jwt.decode(token, { complete: true }).header); // { alg: 'ES256', typ: 'JWT', kid: 'key-2026-05' }

const claims = jwt.verify(token, publicKey, { algorithms: ['ES256'], issuer: 'study-auth', audience: 'orders-api' });
console.log(claims.sub); // 42

try { jwt.sign({ sub: '42' }, publicKey, { algorithm: 'ES256' }); }
catch (err) { console.log(err.message); } // secretOrPrivateKey must be an asymmetric key when using ES256

// Algorithm confusion: forge an HS256 token using the PUBLIC key's PEM text as the HMAC secret.
const pem = publicKey.export({ type: 'spki', format: 'pem' });
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const input = `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64({ sub: '1', role: 'admin' })}`;
const forged = `${input}.${createHmac('sha256', pem).update(input).digest('base64url')}`;
for (const opts of [{}, { algorithms: ['ES256', 'HS256'] }]) {
  try { jwt.verify(forged, pem, opts); console.log('ACCEPTED'); }
  catch (err) { console.log(JSON.stringify(opts), '→', err.message); }
}
// {} → invalid algorithm
// {"algorithms":["ES256","HS256"]} → secretOrPublicKey must be a symmetric key when using HS256
What’s happening
  1. generateKeyPairSync('ec', { namedCurve: 'P-256' }) creates the key pair ES256 needs. In production the private key lives only in the auth service (or a KMS); APIs receive the public half, usually through a JWKS endpoint (next topic).
  2. keyid puts a kid in the header so verifiers know which public key to use — you can see it in the decoded header.
  3. jwt.verify with the public key succeeds. Trying to sign with the public key fails — exactly the property you want: a compromised API server can't mint tokens.
  4. The forged token is the classic algorithm-confusion attack: the public key is public, so an attacker uses its PEM text as an HMAC secret and sets alg: HS256. A naive verifier that picks the algorithm from the token's header and passes "the key" would compute the same HMAC and accept it.
  5. jsonwebtoken 9 blocks it twice: with no algorithms option it infers the allowed list from the key type (a public EC key → only ES256/384/512), so HS256 → invalid algorithm; and even if you wrongly allow HS256, it refuses to use an asymmetric key as an HMAC secret. Still, always pass algorithms explicitly — it documents intent and protects you in libraries that are less strict.
HS256RS256ES256
KeyOne shared secretRSA key pair (2048+ bits)EC key pair (P-256)
Who can verifyAnyone with the secretAnyone with the public keyAnyone with the public key
Who can mintAnyone who can verifyOnly the private-key holderOnly the private-key holder
Signature size32 bytes256 bytes (2048-bit key)64 bytes
Good forOne service issuing and checking its own tokensMany APIs, third-party verifiers, JWKSSame as RS256, smaller tokens

Other practical rules: use a random HS256 secret of at least 32 bytes (randomBytes(32)), never a human-readable phrase; load it from the environment and fail fast if it's missing; allow a small clockTolerance (a few seconds) when issuer and verifier run on different machines.

#Key rotation with kid and JWKS

Advanced

Signing keys shouldn't live forever: they leak, people leave, compliance asks for rotation. But you can't switch keys at an instant, because tokens signed with the old key are still in flight for up to their lifetime. The solution is to let several keys be valid at once and tell verifiers which key signed each token.

That's what the header's kid (key ID) is for, and a JWKS (JSON Web Key Set) is how an issuer publishes its public keys: a JSON document { "keys": [ … ] } at a well-known URL (for OpenID Connect providers, the jwks_uri in /.well-known/openid-configuration). Think of kid as the label on a key ring: the API looks at the label, picks the matching public key from the ring it trusts, and verifies.

jwks.cjs
const { generateKeyPairSync, createPublicKey } = require('node:crypto');
const jwt = require('jsonwebtoken');

// --- Auth service: two key pairs. A is being retired, B is the new signing key.
const keyA = generateKeyPairSync('ec', { namedCurve: 'P-256' });
const keyB = generateKeyPairSync('ec', { namedCurve: 'P-256' });

const jwks = {
  keys: [
    { ...keyA.publicKey.export({ format: 'jwk' }), kid: 'A', alg: 'ES256', use: 'sig' },
    { ...keyB.publicKey.export({ format: 'jwk' }), kid: 'B', alg: 'ES256', use: 'sig' },
  ],
};
console.log(jwks.keys[1]);
// { kty: 'EC', x: 'BnjMByusGvzx…', y: '1PQdHizEx-YA…', crv: 'P-256', kid: 'B', alg: 'ES256', use: 'sig' }

const sign = (privateKey, kid) =>
  jwt.sign({ sub: '42' }, privateKey, { algorithm: 'ES256', keyid: kid, expiresIn: '15m', issuer: 'study-auth', audience: 'orders-api' });

const oldToken = sign(keyA.privateKey, 'A'); // issued before the rotation
const newToken = sign(keyB.privateKey, 'B'); // issued after
const rogue = sign(generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey, 'C');

// --- API: looks keys up by kid in the JWKS it trusts (fetched from a configured URL and cached).
function getKey(header, callback) {
  const jwk = jwks.keys.find((k) => k.kid === header.kid);
  if (!jwk) return callback(new Error(`unknown kid ${header.kid}`));
  callback(null, createPublicKey({ key: jwk, format: 'jwk' }));
}

const opts = { algorithms: ['ES256'], issuer: 'study-auth', audience: 'orders-api' };
for (const [label, token] of [['old (A)', oldToken], ['new (B)', newToken], ['rogue (C)', rogue]]) {
  jwt.verify(token, getKey, opts, (err, claims) => {
    console.log(label, '→', err ? err.message : `sub ${claims.sub}`);
  });
}
// old (A) → sub 42
// new (B) → sub 42
// rogue (C) → error in secret or public key callback: unknown kid C
What’s happening
  1. publicKey.export({ format: 'jwk' }) turns a Node KeyObject into a JSON Web Key: for EC keys that's kty, crv and the curve point x/y (random on every run). We add kid, alg and use: 'sig'. This JSON is what a real /.well-known/jwks.json serves — public keys only.
  2. Tokens are signed with keyid set, so each header carries kid: 'A' or kid: 'B'. The rogue token is signed with a key nobody published, labelled C.
  3. When verify's second argument is a function, jsonwebtoken decodes the header first, calls getKey(header, cb), and waits for the key — so verification must use the callback (async) form.
  4. getKey looks up the kid only in the set we already trust and converts the JWK back to a KeyObject with createPublicKey({ format: 'jwk' }). Old and new tokens both verify — that's the overlap that makes rotation painless. C isn't in the set, so verification fails before any signature math.
  5. The rotation sequence this enables: publish B alongside A → start signing with B → wait at least the longest token lifetime → remove A from the JWKS.

Removing a key is also the "nuclear" revocation: every token signed with it dies at once. It's a tool for a leaked key, not for logging out one user.

#Where to store tokens in the browser

For a browser app, the token has to live somewhere between requests, and each place has a different failure mode. The two attacks to keep in mind: XSS (attacker-injected JavaScript running in your page — see Cross-site scripting (XSS)) and CSRF (another site making the browser send a request to yours with your cookies attached — see Cross-site request forgery (CSRF)).

From my notes: localStorage persists even after the browser is closed; sessionStorage dies with the tab; both are readable by any JavaScript on the page, so one XSS bug and the token is copied to the attacker's server. That's correct, and it's why the notes recommend an HttpOnly cookie: page JavaScript can't read it at all.

StorageReadable by page JS (XSS can steal it)Sent automatically (CSRF risk)Lifetime
localStorageYesNo — your code adds the headerUntil cleared
sessionStorageYesNoUntil the tab closes
JS variable (memory)Only while the page runsNoUntil reload
HttpOnly cookieNoYes — needs SameSite and/or CSRF tokensMax-Age/Expires, or the browser session

My notes' cookie example has a bug before it gets to the cookie, though. Run as written (minus app.listen), the login route crashes:

notes-login.cjs
// Rohit's original example, unchanged apart from the secret and testing with supertest.
const express = require('express');
const jwt = require('jsonwebtoken');
const request = require('supertest');
const app = express();
const SECRET_KEY = 'demo-secret-not-for-production';
app.post('/login', (req, res) => {
  const { username, password } = req.body;
  const user = { id: 1, username };
  const token = jwt.sign(user, SECRET_KEY, { expiresIn: '1h' });
  res.cookie('token', token, { httpOnly: true, secure: true, sameSite: 'Strict', maxAge: 3600000 });
  res.json({ message: 'Login successful' });
});
request(app).post('/login').send({ username: 'rohit', password: 'x' }).then((r) => {
  console.log(r.status); // 500
});
// TypeError: Cannot destructure property 'username' of 'req.body' as it is undefined.
What’s happening
  1. supertest sends a JSON body, but nothing in the app parses it. Without express.json(), Express 5 leaves req.body as undefined (see Parsing request bodies).
  2. Destructuring undefined throws a TypeError synchronously inside the handler. Express catches it and its default error handler answers 500 with the stack trace (in development; just "Internal Server Error" when NODE_ENV=production).
  3. The fix is one line: app.use(express.json()) before the routes. The cookie options themselves were sensible — and sameSite: 'Strict' is fine, Express emits SameSite=Strict whatever the casing.
AdvancedCorrected cookie login with logout

Here's the corrected version, with the cookie read back, and logout. The comments show the real Set-Cookie headers:

cookie-login.cjs
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const request = require('supertest');

const JWT_SECRET = process.env.JWT_SECRET ?? 'demo-secret-not-for-production';
const app = express();
app.use(express.json());   // without this, req.body is undefined in Express 5
app.use(cookieParser());   // fills req.cookies from the Cookie header

app.post('/login', (req, res) => {
  const { username } = req.body; // pretend the password was checked (see bcrypt below)
  const token = jwt.sign({ sub: '42', name: username }, JWT_SECRET, { algorithm: 'HS256', expiresIn: '15m' });
  res.cookie('access_token', token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 15 * 60 * 1000, // milliseconds in Express, seconds in the header
    path: '/',
  });
  res.json({ message: 'Login successful' });
});

app.get('/me', (req, res) => {
  const token = req.cookies.access_token;
  if (!token) return res.status(401).json({ error: 'not logged in' });
  try {
    const claims = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
    res.json({ sub: claims.sub, name: claims.name });
  } catch {
    res.status(401).json({ error: 'invalid token' });
  }
});

app.post('/logout', (req, res) => {
  res.clearCookie('access_token', { httpOnly: true, secure: true, sameSite: 'lax', path: '/' });
  res.sendStatus(204);
});

(async () => {
  const login = await request(app).post('/login').send({ username: 'rohit' });
  const setCookie = login.headers['set-cookie'][0];
  console.log(setCookie.replace(/access_token=[^;]+/, 'access_token=eyJhbGciOi…'));
  // access_token=eyJhbGciOi…; Max-Age=900; Path=/; Expires=Wed, 07 Oct 2026 19:03:08 GMT; HttpOnly; Secure; SameSite=Lax

  const cookie = setCookie.split(';')[0]; // what the browser sends back: name=value only
  const me = await request(app).get('/me').set('Cookie', cookie);
  console.log(me.status, me.body);     // 200 { sub: '42', name: 'rohit' }

  const anon = await request(app).get('/me');
  console.log(anon.status, anon.body); // 401 { error: 'not logged in' }

  const out = await request(app).post('/logout');
  console.log(out.status, out.headers['set-cookie'][0]);
  // 204 access_token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax
})();
What’s happening
  1. res.cookie(name, value, options) builds one Set-Cookie response header. maxAge is in milliseconds in Express (900000), but the header says Max-Age=900 seconds, and Express adds an equivalent Expires date (now + 15 minutes) for older clients.
  2. HttpOnly hides the cookie from document.cookie, so XSS can't read the token. Secure means the browser only sends it over HTTPS. SameSite=Lax means the browser won't attach it to cross-site sub-requests (form POSTs, fetches, images from another site) but will on top-level navigations to your site.
  3. On the next request the browser sends back only Cookie: access_token=… — no attributes. cookie-parser turns that header into req.cookies.access_token, and /me verifies it exactly like a Bearer token.
  4. With no cookie, /me answers 401. Note the cookie's expiry (15 minutes) and the JWT's exp (15 minutes) are separate clocks: the browser drops the cookie when Max-Age runs out, and the server rejects the JWT when exp passes. Keep them aligned, but the server-side check is the one that counts.
  5. res.clearCookie sends the same name with an empty value and an Expires in 1970, which makes the browser delete it. The Path (and Domain, if you set one) must match the original or the browser treats it as a different cookie. Clearing it logs this browser out — it doesn't invalidate a copy of the JWT someone already stole (see JWT limitations and workarounds).
AdvancedReference: auth cookie attributes
AttributeWhat it doesTypical value for an auth cookie
HttpOnlyHidden from page JavaScriptAlways
SecureOnly sent over HTTPSAlways (behind a proxy, set Express's trust proxy: see HTTPS, proxies and trust proxy)
SameSiteStrict: never sent on cross-site requests, not even links. Lax: sent on top-level GET navigations only. None: always sent (requires Secure)Lax for a session; Strict for a refresh-token cookie
Max-Age / ExpiresWhen the browser deletes it; without either it's a "session cookie" that dies with the browserMatch the credential's lifetime
DomainOmitted: only the exact host that set it. Domain=example.com: also every subdomainOmit unless subdomains really need it
PathOnly sent for URLs under this path/, or e.g. /auth/refresh to keep a refresh token off every other request

From my notes, the techStatus cookie used httpOnly, Secure, a 4-hour expiry and a Domain, with the comment "sameSite is lax I think, default, we don't include it". Worth tightening: Express sends no SameSite attribute when you omit it (res.cookie('a', '1') produces just a=1; Path=/), and what happens next depends on the browser — Chromium-based browsers treat a missing SameSite as Lax, but not every browser has adopted that default. Set sameSite explicitly. And a Domain attribute widens the cookie to all subdomains, so only set it if they really need the cookie.

cookie-parser can also sign cookies, which is useful for small values that aren't tokens:

cookie-variants.cjs
const express = require('express');
const cookieParser = require('cookie-parser');
const request = require('supertest');
const app = express();
app.use(cookieParser('cookie-signing-secret-demo'));
app.get('/signed', (req, res) => res.cookie('theme', 'dark', { signed: true, httpOnly: true }).end());
app.get('/read', (req, res) => res.json({ cookies: req.cookies, signedCookies: req.signedCookies }));
(async () => {
  const signed = (await request(app).get('/signed')).headers['set-cookie'][0].split(';')[0];
  console.log(signed); // theme=s%3Adark.Fhy2f8YwHLlo9zOJNFqDYivn3ldiHaqQQNcS5qvhJTc
  console.log((await request(app).get('/read').set('Cookie', signed)).body);
  // { cookies: {}, signedCookies: { theme: 'dark' } }
  const tampered = signed.replace('dark', 'light');
  console.log((await request(app).get('/read').set('Cookie', tampered)).body);
  // { cookies: {}, signedCookies: { theme: false } }
})();
What’s happening
  1. cookieParser(secret) enables signing. { signed: true } makes the value s:dark.<HMAC> (URL-encoded, so s%3A): the original value in the clear, plus an HMAC of it.
  2. On the way back in, cookie-parser checks the HMAC and moves valid signed cookies into req.signedCookies — not req.cookies.
  3. Change dark to light and the HMAC no longer matches, so the value becomes false. Same idea as a JWT signature: tamper-evident, not secret — anyone can read dark.
AdvancedToken storage designs for SPAs

Common designs for single-page apps, from most to least conservative:

  • Backend-for-frontend (BFF): tokens never reach the browser. Your Node server holds them and gives the browser an ordinary HttpOnly session cookie.
  • Access token in memory, refresh token in an HttpOnly cookie scoped to Path=/auth/refresh: XSS can use the page while it's open but can't walk away with a long-lived credential; on reload the app calls /auth/refresh to get a new access token.
  • Access token in an HttpOnly cookie (the example above): simple, but now you need CSRF protection.
  • localStorage: simplest, and the most exposed to XSS. Avoid for anything sensitive.
Does HttpOnly stop XSS?

Your access token is in an HttpOnly; Secure; SameSite=Lax cookie. An attacker finds an XSS hole in your app. What can and can't they do?

Show answer

They can't read the token, but they can still act as the user while the victim has the page open.

What’s happening
  1. HttpOnly removes the cookie from document.cookie, so the injected script can't send the token to the attacker's server. That's real protection: the stolen credential would otherwise be usable from anywhere, for its full lifetime.
  2. But the injected script runs on your origin. If it calls fetch('/api/transfer', { method: 'POST', … }), the browser attaches the cookie automatically — same-site request, so SameSite doesn't block it either.
  3. So HttpOnly turns "token theft" into "session riding while the page is open". It reduces the damage; it doesn't fix XSS. The real fixes are output escaping and a Content Security Policy (see Content Security Policy).

#JWT limitations and workarounds

My notes list three limitations, and they're the ones interviewers expect:

  • No revocation. Once issued, a JWT is valid until exp. Nothing on the server "remembers" it, so there's nothing to delete.
  • Payload is not encrypted. Anything in it is readable by whoever holds the token.
  • Size. Every claim travels on every request.

And three workarounds: short expiry, a server-side blacklist (denylist), and refresh tokens. All three show up in this demo:

jwt-limits.cjs
const jwt = require('jsonwebtoken');
const { randomUUID } = require('node:crypto');
const secret = 'demo-secret-not-for-production';

// 1. Anyone holding the token can read it — no key needed.
const leaky = jwt.sign({ sub: '42', email: 'rohit@example.com', ssnLast4: '1234' }, secret);
const payload = JSON.parse(Buffer.from(leaky.split('.')[1], 'base64url'));
console.log(payload); // { sub: '42', email: 'rohit@example.com', ssnLast4: '1234', iat: … }

// 2. Size: every claim travels on every request.
const small = jwt.sign({ sub: '42' }, secret, { expiresIn: '15m' });
const permissions = Array.from({ length: 60 }, (_, i) => `orders:resource-${i}:read`);
const big = jwt.sign({ sub: '42', permissions }, secret, { expiresIn: '15m' });
console.log(small.length, 'bytes vs', big.length, 'bytes'); // 143 bytes vs 2231 bytes

// 3. Immediate revocation needs state again: a denylist of jti values until they expire.
const denylist = new Map(); // jti -> exp (Redis SET with a TTL in production)
const token = jwt.sign({ sub: '42' }, secret, { expiresIn: '15m', jwtid: randomUUID() });

function verifyWithDenylist(t) {
  const claims = jwt.verify(t, secret, { algorithms: ['HS256'] });
  if (denylist.has(claims.jti)) throw new Error('token revoked');
  return claims;
}

console.log(verifyWithDenylist(token).sub); // 42
const { jti, exp } = jwt.decode(token);
denylist.set(jti, exp); // logout: remember this jti until it would have expired anyway
try { verifyWithDenylist(token); } catch (e) { console.log(e.message); } // token revoked
What’s happening
  1. Readable payload: one Buffer.from(…, 'base64url') and the email and SSN digits are out. The token travels through browsers, proxies and log files, so treat the payload as public. Put an ID in it, and look up sensitive data on the server.
  2. Size: a minimal token is 143 bytes; stuffing 60 permission strings into it makes it 2,231 bytes — sent on every request, and getting close to the ~4 KB per-cookie limit browsers enforce. Large permission sets belong on the server, looked up by sub, or expressed as a few coarse scopes.
  3. Denylist: signing with jwtid adds a unique jti. On logout we record that jti; verifyWithDenylist first does the normal verification, then checks the list. The same token now fails.
  4. The catch: this is a lookup on every request — exactly what JWTs were supposed to avoid. It's still cheaper than full sessions, because the list only holds revoked-but-unexpired tokens, so each entry can be dropped at its exp (a Redis key with a TTL does this for free). With 15-minute tokens the list stays tiny.
AdvancedReference: JWT revocation workarounds
WorkaroundHow it worksCost
Short expiry (5–15 min)A stolen token is useful only brieflyNeeds a way to get new tokens without re-login: refresh tokens
Denylist by jtiReject listed IDs until their expA shared store lookup per request
Token versionUser row has tokenVersion; token carries ver; bump it to kill all of a user's tokensA user lookup per request (often already needed for authorization)
Refresh tokensLong-lived, stored credential, used only to get new short access tokens; revoke it centrallyMore moving parts — next two topics
Rotate the signing keyKills every token signed with itLogs out everyone; only for a leaked key
JWE (encrypted JWT)Payload encrypted, not just signedKey management; jsonwebtoken doesn't support it (the jose library does). Usually simpler to just not put secrets in tokens
AdvancedClarification: where the state still lives

#Access tokens and refresh tokens

Short-lived access tokens fix the revocation problem only if users don't have to log in every 15 minutes. That's the job of the second token. The access token is sent with every API call, so it's exposed often — keep it short-lived (minutes). The refresh token is long-lived (days to weeks), but it's sent only to one endpoint, /auth/refresh, whose only job is to issue a new access token. It's stored server-side (or its hash is), so it can be revoked.

The mental model: an access token is a day pass you flash at every door; a refresh token is your membership card, kept at home and only shown at the front desk to get a new day pass. Losing a day pass costs you a day; the membership card can be cancelled at the desk.

Output
login                      → AT1 (15 min) + RT1 (30 days)
GET /orders   [AT1]        → 200
GET /profile  [AT1]        → 200
            … AT1 expires …
GET /orders   [AT1]        → 401 jwt expired
POST /auth/refresh [RT1]   → AT2 (and, with rotation, RT2)
GET /orders   [AT2]        → 200
AdvancedRunning the access and refresh timeline

Here's that timeline running, with a 2-second access token so the demo doesn't take 15 minutes:

two-tokens.cjs
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { randomBytes } = require('node:crypto');
const request = require('supertest');

const JWT_SECRET = 'demo-secret-not-for-production';
const refreshTokens = new Map(); // token -> userId (hash it and use a DB in real code: next topic)

const app = express();
app.use(cookieParser());

const issueAccessToken = (userId) =>
  jwt.sign({ sub: userId }, JWT_SECRET, { algorithm: 'HS256', expiresIn: 2 }); // 2 seconds, so the demo is quick

app.post('/auth/login', (req, res) => {
  const refreshToken = randomBytes(32).toString('base64url');
  refreshTokens.set(refreshToken, '42');
  res.cookie('rt', refreshToken, { httpOnly: true, secure: true, sameSite: 'strict', path: '/auth/refresh', maxAge: 30 * 864e5 });
  res.json({ accessToken: issueAccessToken('42') });
});

app.post('/auth/refresh', (req, res) => {
  const userId = refreshTokens.get(req.cookies.rt);
  if (!userId) return res.sendStatus(401);
  res.json({ accessToken: issueAccessToken(userId) });
});

app.get('/orders', (req, res) => {
  try {
    const { sub } = jwt.verify(req.get('authorization')?.replace('Bearer ', '') ?? '', JWT_SECRET, { algorithms: ['HS256'] });
    res.json({ user: sub, orders: 2 });
  } catch (err) {
    res.status(401).json({ error: err.message });
  }
});

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

(async () => {
  const login = await request(app).post('/auth/login');
  const setCookie = login.headers['set-cookie'][0];
  console.log('login      ', setCookie.replace(/rt=[^;]+/, 'rt=…'));
  const rtCookie = setCookie.split(';')[0];
  let at = login.body.accessToken;

  const orders = () => request(app).get('/orders').auth(at, { type: 'bearer' });
  let r = await orders();                         console.log('orders     ', r.status, r.body);
  await sleep(2100);
  r = await orders();                             console.log('orders     ', r.status, r.body);
  r = await request(app).post('/auth/refresh').set('Cookie', rtCookie);
  at = r.body.accessToken;                        console.log('refresh    ', r.status, 'new access token');
  r = await orders();                             console.log('orders     ', r.status, r.body);
})();
AdvancedOutput: expiry and refresh in action
Output
login       rt=…; Max-Age=2592000; Path=/auth/refresh; Expires=Fri, 06 Nov 2026 18:58:02 GMT; HttpOnly; Secure; SameSite=Strict
orders      200 { user: '42', orders: 2 }
orders      401 { error: 'jwt expired' }
refresh     200 new access token
orders      200 { user: '42', orders: 2 }
What’s happening
  1. Login returns two different kinds of credential. The access token is a JWT in the response body (the SPA keeps it in memory). The refresh token is not a JWT — just 32 random bytes — because the server looks it up anyway, so there's nothing to gain from making it self-contained.
  2. The refresh cookie has Path=/auth/refresh, so the browser only attaches it to that one endpoint — it never rides along on /orders. SameSite=Strict is fine here because only your own front end calls /auth/refresh. Max-Age=2592000 is 30 days in seconds.
  3. The first /orders call passes: the JWT is verified locally, no lookup. After 2.1 seconds the same token fails with jwt expired — the API never had to be told anything; the token simply ran out.
  4. The client reacts to the 401 by calling /auth/refresh. The browser attaches the rt cookie; the server looks it up (this is where revocation lives — delete the entry and the user is out at the next refresh) and issues a fresh access token.
  5. The retry with the new token succeeds. Each access token is short, each refresh is a deliberate server-side check: you get local verification on the hot path and central control on a slower one.
AdvancedEdge case: concurrent refresh with rotation
AdvancedRefresh tokens are optional

JWTs don't require refresh tokens: "log in → access token → expires → log in again" is a valid design for low-stakes apps or short sessions.

Why not a 30-day access token?

Refresh tokens add an endpoint, a database table and client retry logic. Why not just issue a 30-day access token and skip all that?

Show answer

Because a 30-day JWT is a 30-day irrevocable credential that's sent on every request.

What’s happening
  1. The access token goes to every API, through every proxy and into whatever logs headers. The more places a credential travels, the likelier it leaks.
  2. Being a self-contained JWT, nothing on the server can cancel it without adding a per-request lookup (a denylist) — which undoes the reason for using a JWT.
  3. With a 15-minute access token, a leaked one is useful for at most 15 minutes. The long-lived credential (the refresh token) travels only to /auth/refresh, is checked against server-side state every time, and can be revoked — or rotated, so theft is detectable.

#Refresh-token rotation and reuse detection

A plain refresh token has the same problem as a long access token, just better hidden: if it's stolen, the attacker can mint access tokens for 30 days, and you'd never know. Rotation makes every refresh token single-use: each time one is used successfully, it's marked used and a new one is issued in its place. The chain of tokens descending from one login is a token family.

The mental model: numbered tear-off ticket stubs. Each stub buys you an access token and a new stub. If a stub that was already torn off ever comes back, someone photocopied it — and since you can't tell whether the person holding it now is the real user or the copier, you cancel the whole book. That's reuse detection.

refresh-rotation.cjs
const { randomBytes, createHash, randomUUID } = require('node:crypto');

const sha256 = (s) => createHash('sha256').update(s).digest('hex');
const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000;

const tokens = new Map();          // sha256(token) -> record; the raw token is never stored
const revokedFamilies = new Set();
let counter = 0;                   // only to give tokens readable names in the output

function issueRefreshToken(userId, familyId = randomUUID()) {
  const raw = randomBytes(32).toString('base64url');
  tokens.set(sha256(raw), {
    name: `RT${++counter}`, userId, familyId, status: 'active', expiresAt: Date.now() + THIRTY_DAYS,
  });
  return raw;
}

function refresh(presented) {
  const record = tokens.get(sha256(presented));
  if (!record) return { error: 'unknown token' };
  if (revokedFamilies.has(record.familyId)) return { error: 'family revoked' };
  if (record.expiresAt <= Date.now()) return { error: 'expired' };
  if (record.status === 'used') {
    revokedFamilies.add(record.familyId); // an old token came back: someone kept a copy
    return { error: 'reuse detected, family revoked' };
  }
  record.status = 'used';
  const successor = issueRefreshToken(record.userId, record.familyId);
  return { accessToken: `new access JWT for ${record.userId}`, refreshToken: successor };
}

function dump(step, result) {
  const family = [...tokens.values()]
    .map((r) => `${r.name}=${revokedFamilies.has(r.familyId) ? 'revoked' : r.status}`)
    .join('  ');
  const got = result.refreshToken ? `ok, got ${tokens.get(sha256(result.refreshToken)).name}` : result.error;
  console.log(`${step.padEnd(26)} ${got.padEnd(32)} ${family}`);
}

const rt1 = issueRefreshToken('rohit');   // login
const attackerCopy = rt1;                 // the attacker copies RT1

let r = refresh(rt1);              dump('Rohit sends RT1', r);
r = refresh(r.refreshToken);       dump('Rohit sends RT2', r);
const rt3 = r.refreshToken;
r = refresh(attackerCopy);         dump('Attacker sends RT1', r);
r = refresh(rt3);                  dump('Rohit sends RT3', r);
AdvancedOutput: reuse detected, family revoked
Output
Rohit sends RT1            ok, got RT2                      RT1=used  RT2=active
Rohit sends RT2            ok, got RT3                      RT1=used  RT2=used  RT3=active
Attacker sends RT1         reuse detected, family revoked   RT1=revoked  RT2=revoked  RT3=revoked
Rohit sends RT3            family revoked                   RT1=revoked  RT2=revoked  RT3=revoked
What’s happening
  1. Login creates RT1 with a fresh familyId. Only sha256(RT1) is stored, like a password hash (a fast hash is fine here because the token is 256 random bits, not a guessable password). A leaked database table therefore contains no usable tokens. The attacker gets a copy of the raw RT1.
  2. Rohit sends RT1: found, family not revoked, not expired, status active → mark it used, issue RT2 in the same family. Family: RT1=used RT2=active.
  3. Rohit sends RT2: same path → RT1=used RT2=used RT3=active. Rohit's client always holds exactly one active token: the newest.
  4. Attacker sends RT1: the record exists but its status is used. A legitimate client never sends a token it has already exchanged, so somebody is holding a copy. The server can't tell who, so it revokes the entire family — RT3, which Rohit holds, dies too.
  5. Rohit sends RT3: family revoked. Rohit has to log in again (an annoyance), but the attacker's access is cut off and the reuse is a security event you can alert on. Already-issued access tokens keep working until their exp, which is why those stay short.
AdvancedEdge case: the attacker refreshes first

The uncomfortable case is when the attacker refreshes first:

Output (attacker uses the stolen RT1 before Rohit does)
Attacker sends RT1         ok, got RT2                      RT1=used  RT2=active
Attacker sends RT2         ok, got RT3                      RT1=used  RT2=used  RT3=active
Rohit sends RT1            reuse detected, family revoked   RT1=revoked  RT2=revoked  RT3=revoked
Attacker sends RT3         family revoked                   RT1=revoked  RT2=revoked  RT3=revoked
What’s happening
  1. The attacker presents RT1 first. To the server, that's indistinguishable from a normal refresh, so the attacker gets RT2 — and later RT3. Rotation doesn't make a stolen token unusable; it makes competing use detectable.
  2. Detection happens only when Rohit's client presents its now-stale RT1. That reuse revokes the family, and the attacker's RT3 dies with it.
  3. So the attacker's window is "until the real client next refreshes". Rohit is logged out until he signs in again, which is the price of the detection. Pair rotation with an absolute family lifetime (e.g. 30 days since login, however often it rotates) so even an undetected thief eventually loses access.
AdvancedAtomic rotation across processes

In one Node process, refresh() is safe as written because there's no await between reading the record and marking it used: no other request can run in between (see How one process serves many requests). With a real database, two concurrent requests can both read active. Make the consume step atomic with a conditional update and check how many rows changed:

rotate-sql.cjs (illustrative — assumes a PostgreSQL client named db)
await db.query('BEGIN');
const used = await db.query(
  `UPDATE refresh_tokens SET status = 'used', used_at = now()
    WHERE token_hash = $1 AND status = 'active' AND expires_at > now()
    RETURNING user_id, family_id`,
  [sha256(presented)],
);
if (used.rowCount === 0) {
  // Missing, expired, or already used (reuse or a lost race): look it up and revoke the family if it exists.
  await db.query(`UPDATE refresh_tokens SET status = 'revoked' WHERE family_id =
    (SELECT family_id FROM refresh_tokens WHERE token_hash = $1)`, [sha256(presented)]);
  await db.query('COMMIT');
  return res.sendStatus(401);
}
await db.query(`INSERT INTO refresh_tokens (token_hash, user_id, family_id, status, expires_at)
  VALUES ($1, $2, $3, 'active', $4)`, [sha256(successor), used.rows[0].user_id, used.rows[0].family_id, expiresAt]);
await db.query('COMMIT');
What’s happening
  1. The UPDATE … WHERE status = 'active' both checks and consumes in one statement. If two requests race with the same token, the database lets only one of them change the row; the other sees rowCount === 0.
  2. Zero rows means "not a valid active token" — unknown, expired, already used, or the loser of a race. The sketch treats every one of those as suspicious and revokes the family; a gentler policy could allow a reuse within a few seconds of rotation (two tabs) before revoking.
  3. The successor is inserted in the same transaction, so there's never a moment where the old token is consumed but no new one exists (or vice versa).
Who notices, and when?

An attacker steals RT1 and uses it first. The real user's app doesn't refresh until 10 minutes later. What happens during those 10 minutes, and what happens at minute 10?

Show answer

For 10 minutes the attacker has a working token chain; at minute 10 the whole family is revoked, logging out both the attacker and the user.

What’s happening
  1. The attacker's RT1 is the first presentation, so it succeeds: RT1 becomes used, the attacker gets RT2 and a fresh access token.
  2. While the real client stays idle, nothing looks wrong to the server — every presentation is of the newest active token.
  3. At minute 10 the real client sends RT1, which is already used → reuse detected → every token in the family is revoked, including the attacker's latest.
  4. Any access token the attacker already holds stays valid until its exp (at most 15 minutes in our policy) unless the API also checks a denylist or token version.

#Sessions vs JWTs

There are two ways to remember that a browser is logged in. A session gives the client an opaque ID — a random string that means nothing by itself — and keeps the real state (userId, roles, CSRF token…) on the server, looked up on every request. A JWT gives the client the state itself, signed, so the server can trust it without a lookup.

The mental model: a coat-check ticket vs a notarised letter. The ticket is worthless without the cloakroom's records, so the cloakroom can cancel it instantly. The letter carries its own proof, so anyone can check it anywhere — but you can't un-sign it once it's out.

session-vs-jwt.cjs
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { randomBytes } = require('node:crypto');
const request = require('supertest');

const JWT_SECRET = 'demo-secret-not-for-production';
const sessions = new Map(); // sid -> { userId }   (Redis in production)

const app = express();
app.use(cookieParser());

app.post('/login', (req, res) => {
  const sid = randomBytes(32).toString('base64url');           // opaque: means nothing by itself
  sessions.set(sid, { userId: '42' });
  const accessJwt = jwt.sign({ sub: '42' }, JWT_SECRET, { expiresIn: '15m' }); // self-contained
  res.cookie('sid', sid, { httpOnly: true, secure: true, sameSite: 'lax' });
  res.json({ accessJwt });
});

app.get('/me-session', (req, res) => {
  const session = sessions.get(req.cookies.sid);                // one lookup per request
  session ? res.json({ via: 'session', userId: session.userId }) : res.sendStatus(401);
});

app.get('/me-jwt', (req, res) => {
  try {
    const claims = jwt.verify(req.get('authorization')?.slice(7) ?? '', JWT_SECRET, { algorithms: ['HS256'] });
    res.json({ via: 'jwt', userId: claims.sub });                 // no lookup at all
  } catch {
    res.sendStatus(401);
  }
});

app.post('/admin/ban/:userId', (req, res) => {
  for (const [sid, s] of sessions) if (s.userId === req.params.userId) sessions.delete(sid);
  res.sendStatus(204);
});

(async () => {
  const login = await request(app).post('/login');
  const cookie = login.headers['set-cookie'][0].split(';')[0];
  const bearer = `Bearer ${login.body.accessJwt}`;
  const both = async (when) => {
    const s = await request(app).get('/me-session').set('Cookie', cookie);
    const j = await request(app).get('/me-jwt').set('Authorization', bearer);
    console.log(when.padEnd(12), 'session:', s.status, '  jwt:', j.status);
  };
  await both('before ban');   // before ban   session: 200   jwt: 200
  await request(app).post('/admin/ban/42');
  await both('after ban');    // after ban    session: 401   jwt: 200
})();
What’s happening
  1. Login creates both kinds of credential for user 42: a random sid stored in the sessions map and sent as an HttpOnly cookie, and a 15-minute JWT returned in the body.
  2. /me-session has to look the sid up — that's a Redis round trip in production — but the answer always reflects the server's current state. /me-jwt only does HMAC math; the user ID comes out of the token itself.
  3. Before the ban, both say 200.
  4. The ban deletes user 42's sessions. The very next /me-session call finds nothing → 401. Revocation is instant because the truth lives on the server.
  5. /me-jwt still says 200: the JWT is valid until exp, and the API has no reason to doubt it. That's the trade-off in one line — unless you add a denylist or token-version lookup, which is a session lookup by another name.
AdvancedReference: sessions vs JWTs
Opaque session IDSelf-contained JWT
What the client holdsRandom referenceSigned claims
Per-request costStore lookup (Redis/DB)Signature check, no I/O
RevocationInstant: delete the sessionWait for exp, or add a lookup
Size~43 charactersHundreds of bytes to KBs
Data freshnessAlways currentStale until re-issued (role changes lag)
Verifiable by other servicesOnly by calling the session storeYes, with the key (public key for RS/ES)
Typical fitA web app and its own backendAPIs, microservices, mobile, third-party identity

In Express, sessions are usually express-session. It isn't installed in this site's sandbox, so this is illustrative code for express-session 1.x with a Redis store (connect-redis); it assumes a connected redisClient, a users service and a verifyPassword helper:

session-setup.cjs (illustrative)
const session = require('express-session');
const { RedisStore } = require('connect-redis');

app.set('trust proxy', 1); // TLS ends at the load balancer; needed for `secure` cookies
app.use(express.urlencoded({ extended: false }));
app.use(session({
  name: 'sid',
  secret: process.env.SESSION_SECRET,           // signs the cookie; never hard-code it
  store: new RedisStore({ client: redisClient }),
  resave: false,                                // don't re-save unchanged sessions
  saveUninitialized: false,                     // no session (and no cookie) until you store something
  cookie: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 8 * 60 * 60 * 1000 },
}));

app.post('/login', async (req, res, next) => {
  const user = await users.findByEmail(req.body.email);
  if (!user || !(await verifyPassword(req.body.password, user.passwordHash))) return res.sendStatus(401);
  req.session.regenerate((err) => {             // new session ID at login: prevents session fixation
    if (err) return next(err);
    req.session.userId = user.id;
    res.sendStatus(204);
  });
});
What’s happening
  1. session({...}) adds req.session to every request. On the way in, it reads the signed sid cookie, loads the matching object from the store, and on the way out saves it back if it changed.
  2. The default store is an in-process MemoryStore, which leaks memory and isn't shared between processes — fine for a demo, wrong for production. A shared store (Redis) is what lets several Node processes or pods see the same sessions.
  3. saveUninitialized: false avoids creating a session and cookie for every anonymous visitor — my notes' Passport example had true.
  4. req.session.regenerate at login replaces the session ID. Without it, an attacker who planted a known session ID in the victim's browser before login (session fixation) would share the logged-in session afterwards.
AdvancedJWTs on Kubernetes: needed or not

Is JWT needed in the Kubernetes environment?

This question is a heading in my notes. Short answer: no — Kubernetes doesn't require JWTs, and sessions work fine on it. What Kubernetes changes is that you run several replicas behind a load balancer and pods come and go. That breaks only one thing: state kept inside a process. An in-memory session created by pod A is invisible to pod B.

You have two good answers and one bad one:

  • Shared session store (Redis): every pod looks sessions up in the same place. Simple, instant revocation, one more dependency.
  • Self-contained tokens (JWT): every pod verifies locally with the same key. No shared store on the hot path, harder revocation. Most attractive when many services need to verify identity, or an external identity provider issues the tokens.
  • Sticky sessions at the load balancer: works until a pod restarts or scales down, then those users are logged out. A workaround, not a design.

(Kubernetes does use JWTs internally — service-account tokens are JWTs that let pods authenticate to the API server — but that's workload identity, unrelated to how your users log in. See also Do you need cluster on Kubernetes?.)

#OAuth 2.0: the four roles

OAuth 2.0 solves delegated authorization: letting one app act on your data in another app without giving it your password. Before OAuth, "connect your Google Drive" meant typing your Google password into a third-party site, which then had full, permanent access to everything. With OAuth, you sign in at Google, approve a limited set of permissions, and the third-party app receives a token that works only for those permissions, can expire, and can be revoked without changing your password.

The classic analogy is a valet key: it starts the car and opens the doors, but not the boot or the glovebox, and you never hand over your house keys.

From my notes, the four roles, with Canva importing files from Google Drive as the running example:

RoleWho it isIn the example
Resource ownerThe person who owns the data and can grant accessYou
ClientThe app that wants access on your behalf — a backend, SPA, mobile app or CLICanva
Authorization serverAuthenticates you, shows the consent screen, issues codes and tokensaccounts.google.com / oauth2.googleapis.com
Resource serverThe API holding the data; validates the access token on each callThe Google Drive API

And the credentials that move between them:

CredentialIssued toSent toPurpose
Authorization codeClient, via the browser redirectAuthorization server's token endpoint, onceShort-lived, single-use voucher to get tokens
Access tokenClientResource server, as Authorization: Bearer …Call the API; may be a JWT or opaque
Refresh token (optional)ClientAuthorization server's token endpointGet new access tokens without the user
ID token (OIDC only)ClientNobody — the client reads itTells the client who logged in

Scopes say what's being requested (drive.readonly-style strings; use the provider's exact registered names). The user can approve fewer than asked for, and the resource server still enforces its own rules — a scope grants a class of access, not every object in it.

Clients come in two kinds. A confidential client runs on a server and can keep a client_secret. A public client — a browser SPA, a mobile app, a CLI — ships its code to users, so any secret inside it is readable by anyone. The client_id is never secret; it's just the app's public name.

The concise flow from my notes, which is accurate:

  1. Client redirects the user to the authorization server with client_id, redirect_uri, scope, state and a PKCE code_challenge.
  2. The user authenticates there and approves the requested permissions.
  3. The authorization server redirects back to the client with a short-lived authorization code.
  4. The client exchanges the code at the token endpoint with the PKCE code_verifier (and its client secret, if it's a confidential client).
  5. The authorization server returns an access token and, when applicable, a refresh token.
  6. The client calls the resource server with Authorization: Bearer <access token>.
  7. When the access token expires, the client uses the refresh token to get a new one without sending the user through login again.

That's the authorization code grant, and it's the one to know (next topic). The others you might be asked about: client credentials (a service authenticating as itself, no user — machine-to-machine), and the device flow (TVs and CLIs: "go to this URL and enter this code"). The old implicit grant (tokens straight in the redirect URL) and the password grant (the app collects your password) are discouraged by current OAuth security guidance and dropped from the OAuth 2.1 draft.

Is OAuth an authentication protocol?

A teammate says "we use OAuth for login, so the access token proves who the user is." What's wrong with that?

Show answer

OAuth 2.0 by itself is authorization: an access token says "the bearer may call this API with these scopes", not "this is user X logging into your app".

What’s happening
  1. An access token is meant for the resource server. Its format isn't defined by OAuth — it may be opaque to the client — and nothing in plain OAuth says it was issued for your app.
  2. If your app accepts "any valid Google access token" as proof of login, an attacker could take an access token issued to some other app (one they control, which you signed into) and present it to yours.
  3. Login needs a token that's about authentication and is addressed to your app — the ID token from OpenID Connect, with aud equal to your client_id. See OpenID Connect and ID tokens.

#Authorization Code flow with PKCE

The authorization code flow is shaped by one constraint: the browser is the only thing that can carry the user to the authorization server and back, but the browser is a leaky place — URLs end up in history, logs, Referer headers and, on mobile, other apps can register the same redirect scheme. So the flow sends only a short-lived, single-use code through the browser (the front channel), and gets the real tokens over a direct HTTPS call from the client to the token endpoint (the back channel).

PKCE (Proof Key for Code Exchange, pronounced "pixie") closes the remaining gap: what if someone intercepts the code? Before redirecting, the client invents a random secret, the code verifier, and sends only its SHA-256 hash, the code challenge. When redeeming the code, it must present the verifier itself. The authorization server hashes it and compares. An attacker who grabbed the code from a URL doesn't have the verifier, and can't derive it from the challenge.

The mental model: at a coat check you hand over a sealed fingerprint of a password (the challenge) and get a ticket (the code). To collect, you need the ticket and the password itself (the verifier). A stolen ticket is useless without the password, and seeing the fingerprint doesn't reveal it.

front channel redirects through the browser back channel server to server, over TLS Browser user Client app your Node backend Authorization server e.g. accounts.google.com Resource server the API ① GET /login ② new state + verifier challenge = SHA256(verifier) ③ 302 → /authorize?… ④ GET /authorize client_id · redirect_uri · scope · state · code_challenge · S256 ⑤ user signs in and approves scopes ⑥ 302 → redirect_uri?code=…&state=… ⑦ GET /callback ⑧ state matches session? ⑨ POST /token code + code_verifier (+ secret) ⑩ SHA256(verifier) = challenge? ⑪ 200 tokens access · refresh · id token ⑫ Authorization: Bearer … ⑬ 200 data
Authorization Code + PKCE. Only the challenge and the one-time code cross the browser; the verifier and the tokens travel server to server.

First, the PKCE values themselves — this is all the cryptography there is:

pkce.cjs
const { randomBytes, createHash } = require('node:crypto');

// One fresh set per login attempt.
const state = randomBytes(32).toString('base64url');
const verifier = randomBytes(32).toString('base64url');
const challenge = createHash('sha256').update(verifier, 'ascii').digest('base64url');

console.log({ state, verifier, challenge });
// {
//   state: '3N-e_U0-emkR7iC4HXsfqiR-X3Qkj4hzijfqCdWMd24',
//   verifier: 'L6u74G6iS7qEBzFe2fi2Rvb1pfN6xL9uqPLp5IB_OxM',
//   challenge: 'VJUTsb0SLvnA6qVsaCKt3YXQTM1x6tttqTWs9A5nRyY'
// }   (different every run)
console.log(verifier.length, challenge.length); // 43 43

// What the authorization server does at the token endpoint:
const recomputed = createHash('sha256').update(verifier, 'ascii').digest('base64url');
console.log(recomputed === challenge); // true

// Fixed example verifier so the result can be compared:
const v = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
console.log(createHash('sha256').update(v, 'ascii').digest('base64url'));
// E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
What’s happening
  1. randomBytes(32) gives 256 bits of randomness; base64url turns 32 bytes into exactly 43 URL-safe characters. The PKCE spec requires a verifier of 43–128 characters from the URL-safe set, so this is the shortest legal one with full entropy.
  2. state is generated the same way but serves a different purpose (next topic). Both are created per login attempt and stored server-side, tied to the browser's session.
  3. The challenge is BASE64URL(SHA256(verifier)) — that's what code_challenge_method=S256 means. SHA-256 outputs 32 bytes, so the challenge is also 43 characters.
  4. Only the challenge goes into the authorization URL. SHA-256 is one-way, so anyone who sees the URL (browser history, a proxy log, a malicious app on the device) learns nothing that lets them produce the verifier.
  5. At the token endpoint the server recomputes the hash of the presented verifier and compares it with the challenge it stored with the code. The last line uses the worked-example verifier from the PKCE spec; the output matches the challenge the spec gives for it, which is a handy self-test for any implementation.
AdvancedThe full PKCE client in Express

Now the whole client. This is a runnable Express client — it was run against a small fake authorization server (built just for testing, not shown), with supertest playing the browser and following each redirect by hand. express-session isn't available, so a Map plus a signed cookie stands in for it:

oauth-client.cjs
const express = require('express');
const cookieParser = require('cookie-parser');
const { randomBytes, createHash, randomUUID } = require('node:crypto');

const AUTHORIZATION_ENDPOINT = process.env.AS_URL + '/authorize';
const TOKEN_ENDPOINT = process.env.AS_URL + '/token';
const CLIENT_ID = 'demo-client';
const REDIRECT_URI = 'http://localhost:3000/oauth/callback';

const flows = new Map(); // flowId -> { state, verifier, expiresAt }   (stands in for express-session)
const app = express();
app.use(cookieParser(process.env.COOKIE_SECRET));

app.get('/login', (req, res) => {
  const state = randomBytes(32).toString('base64url');
  const verifier = randomBytes(32).toString('base64url');
  const challenge = createHash('sha256').update(verifier, 'ascii').digest('base64url');

  const flowId = randomUUID();
  flows.set(flowId, { state, verifier, expiresAt: Date.now() + 10 * 60_000 });
  res.cookie('oauth_flow', flowId, { signed: true, httpOnly: true, secure: true, sameSite: 'lax', maxAge: 10 * 60_000 });

  const url = new URL(AUTHORIZATION_ENDPOINT);
  url.search = new URLSearchParams({
    response_type: 'code',
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: 'openid profile',
    state,
    code_challenge: challenge,
    code_challenge_method: 'S256',
  });
  res.redirect(url.href);
});

app.get('/oauth/callback', async (req, res, next) => {
  const flowId = req.signedCookies.oauth_flow;
  const flow = flows.get(flowId);
  flows.delete(flowId);           // single use, whatever happens next
  res.clearCookie('oauth_flow');

  if (!flow || flow.expiresAt < Date.now()) return res.status(400).send('No login in progress');
  if (typeof req.query.state !== 'string' || req.query.state !== flow.state) return res.status(400).send('State mismatch');
  if (req.query.error) return res.status(400).send(`Provider said: ${req.query.error}`);
  if (typeof req.query.code !== 'string') return res.status(400).send('Missing code');

  try {
    const response = await fetch(TOKEN_ENDPOINT, {
      method: 'POST',
      headers: { 'content-type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code: req.query.code,
        redirect_uri: REDIRECT_URI,
        client_id: CLIENT_ID,
        code_verifier: flow.verifier,
      }),
    });
    const tokens = await response.json();
    if (!response.ok) return res.status(502).json(tokens);
    // Real app: validate the ID token, find/create the user, start a session, store tokens server-side.
    res.json({ got: Object.keys(tokens), token_type: tokens.token_type, expires_in: tokens.expires_in });
  } catch (err) {
    next(err);
  }
});

module.exports = { app, flows };
AdvancedOutput: the three browser hops

The three HTTP hops the browser makes, as captured in the test run (authorization server host renamed to auth.example.com):

Output
① GET /login
   302 Location: https://auth.example.com/authorize?response_type=code&client_id=demo-client
       &redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Foauth%2Fcallback&scope=openid+profile
       &state=PEY6XM2xFBNoehiM6sp5jR0XBZF823VyJHEoQJXPhd0
       &code_challenge=mGqY9VxfKV5tTU7Xt7Xu4h6k9ldGmPYtaPsuUPyCj1A&code_challenge_method=S256
   Set-Cookie: oauth_flow=s%3Adda2b98f-fa25-4c1b-9ec6-49297a53518c.xTLdLXXW…; Max-Age=600; Path=/; Expires=…; HttpOnly; Secure; SameSite=Lax

② GET https://auth.example.com/authorize?…   (user signs in and consents)
   302 Location: http://localhost:3000/oauth/callback?code=MlPoywa68MUDeK8lq-AJDQ
       &state=PEY6XM2xFBNoehiM6sp5jR0XBZF823VyJHEoQJXPhd0

③ GET /oauth/callback?code=…&state=…   Cookie: oauth_flow=…
   (client POSTs code + code_verifier to /token)
   200 { got: [ 'access_token', 'token_type', 'expires_in', 'refresh_token', 'scope' ], token_type: 'Bearer', expires_in: 3600 }
What’s happening
  1. GET /login — the client makes a fresh state, verifier and challenge, saves { state, verifier } under a random flowId, and hands the browser that flowId in a signed, HttpOnly cookie that lives 10 minutes. It then answers 302 to the authorization endpoint with seven query parameters: response_type=code (I want a code, not a token), client_id (who I am), redirect_uri (where to send the user back — must exactly match one registered for this client), scope, state, code_challenge and code_challenge_method=S256. The verifier does not appear anywhere in the URL.
  2. GET /authorize — the browser follows the redirect to the authorization server. It checks the client_id and that redirect_uri is registered (if not, it must show an error, never redirect — otherwise it becomes an open redirector for codes), shows its own login page and consent screen, and stores the code_challenge alongside the code it's about to issue. Your app never sees the user's password.
  3. 302 back — the authorization server redirects to redirect_uri with code (short-lived, single-use) and the same state it was given. This is a top-level navigation from the provider's site to yours, which is why the flow cookie is SameSite=Lax: a Strict cookie would not be sent on this cross-site navigation and every login would fail with "No login in progress".
  4. GET /oauth/callback — the client loads the flow by the cookie and deletes it immediately, so the same callback can't be processed twice. It rejects: no flow or expired flow; state missing, not a string (?state=a&state=b would arrive as an array) or different; an error parameter from the provider (e.g. the user clicked "Deny"); a missing code.
  5. POST /token — server to server, form-encoded: grant_type=authorization_code, the code, the same redirect_uri, the client_id and the code_verifier. A confidential client also authenticates here (client secret or a stronger method). The authorization server checks the code is unused and unexpired, was issued to this client_id and redirect_uri, and that SHA256(code_verifier) equals the stored challenge. Then it returns access_token, token_type: "Bearer", expires_in, and optionally refresh_token and (for OIDC) id_token.
  6. The client now holds tokens and the browser never saw them. A real app would validate the ID token, create its own session for the user, and keep the provider's tokens server-side for later API calls.
AdvancedOutput: blocked attack attempts

The same test run also tried the attacks the flow is designed to stop:

Output
replayed callback (same code + cookie again)            → 400 No login in progress
callback with the attacker's code and a made-up state   → 400 State mismatch
stolen code redeemed at /token with a guessed verifier  → 400 { error: 'invalid_grant', error_description: 'PKCE verification failed' }
What’s happening
  1. Replay: the flow record was deleted on first use, so the second callback finds nothing. (A real authorization server also refuses to redeem a code twice.)
  2. Forged callback: the victim's browser has a live flow, but the attacker's link carries a state that doesn't match it → rejected before any token request. That's the job of state (next topic).
  3. Stolen code: the attacker goes straight to the token endpoint with the code but without the verifier. The authorization server hashes whatever verifier they send, it doesn't match the stored challenge, and the code is refused (and, being single-use, is now burned).
AdvancedRecommendation: PKCE for every client
AdvancedUse a maintained OAuth library

In production, don't hand-roll the protocol: use a maintained OAuth/OIDC client library (for example openid-client, or a Passport strategy) that also handles discovery, ID-token validation and error cases. The point of the example is to understand what that library sends.

Why not send the verifier in the first redirect?

PKCE could have been "send a random secret in the authorization request, send it again at the token endpoint". Why send a hash first and the secret later?

Show answer

Because the authorization request travels through the front channel, where it can be observed; the token request doesn't.

What’s happening
  1. Anything in the /authorize URL can end up in browser history, server logs, Referer headers or the hands of another app on the device. If the secret itself were there, whoever intercepted the code could intercept the secret from the same URL.
  2. Sending SHA256(verifier) instead reveals nothing usable: the hash can't be reversed, and the server needs the preimage at the token endpoint.
  3. The verifier is revealed only once, in the direct TLS request from the client to the token endpoint, after the code has already been issued and bound to the challenge. That ordering is the whole trick.

#The state parameter

state answers a simple question at the callback: "is this response the answer to a request this browser started?" The client generates an unguessable value, ties it to the user's session, sends it in the authorization request, and the authorization server echoes it back unchanged. If the callback's state doesn't match a live, unused flow in this session, the response is rejected.

What's the attack? Without state, an attacker can start a login with their own account, stop before the callback, and trick the victim's browser into loading https://yourapp.com/oauth/callback?code=ATTACKER_CODE (an image tag, a link). Your app redeems the code and logs the victim into the attacker's account — or connects the attacker's Google Drive to the victim's profile — so whatever the victim uploads next lands where the attacker can see it. This is login CSRF, a cousin of Cross-site request forgery (CSRF).

The check has a subtle trap:

state-check.cjs
// ❌ Looks like a check, but undefined === undefined.
function badCheck(session, query) {
  return session.oauthState === query.state;
}

// ✅ Both must be non-empty strings, and they must match.
function goodCheck(session, query) {
  const expected = session.oauthState;
  const received = query.state;
  if (typeof expected !== 'string' || typeof received !== 'string') return false;
  return expected.length > 0 && expected === received;
}

const freshBrowser = {};                         // victim never started a login here
const injected = { code: 'ATTACKER_CODE' };      // attacker sends a callback with no state
console.log(badCheck(freshBrowser, injected));   // true  ← accepted!
console.log(goodCheck(freshBrowser, injected));  // false

const session = { oauthState: 'PEY6XM2xFBNoehiM6sp5jR0XBZF823VyJHEoQJXPhd0' };
console.log(goodCheck(session, { state: 'PEY6XM2xFBNoehiM6sp5jR0XBZF823VyJHEoQJXPhd0' })); // true
console.log(goodCheck(session, { state: ['PEY6XM2xFBNoehiM6sp5jR0XBZF823VyJHEoQJXPhd0'] })); // false
What’s happening
  1. The victim's browser has never started a login, so session.oauthState is undefined. The attacker's injected callback simply leaves out state, so query.state is undefined too.
  2. badCheck compares them: undefined === undefined is true, so the forged callback is accepted. The code looks like validation, and passes every test where a login was actually started.
  3. goodCheck first insists both sides are strings, so a missing expected value or a missing received value fails closed → false.
  4. A genuine callback with the matching value passes. A value sent twice (?state=x&state=x) arrives in Express as an array, and the type check rejects it instead of letting ==-style coercion or includes logic do something surprising.
  5. Two more rules the example in the previous topic follows: store state per attempt (one global "current state" slot breaks when the user starts logins in two tabs), and consume it on first use so a callback can't be replayed.
AdvancedCorrection: what protects against login CSRF
AdvancedMemory aid: state, PKCE and nonce

Memory aid: state ties the callback to this session's request; PKCE ties the code redemption to this flow's secret; the ID token's nonce ties the identity to this login attempt (next topic).

Spot the bug
JavaScript
app.get('/oauth/callback', async (req, res) => {
  if (req.query.state !== req.session.oauthState) return res.status(400).end();
  const tokens = await exchangeCode(req.query.code, req.session.verifier);
  // …
});

An attacker sends a victim, who has no login in progress, to /oauth/callback?code=ATTACKER_CODE. Does the check stop it?

Show answer

No. Both sides are undefined, so !== is false and the handler continues.

What’s happening
  1. No state in the query → req.query.state is undefined. No login started in this session → req.session.oauthState is undefined.
  2. undefined !== undefined is false, so the early return doesn't happen.
  3. Whether the attack then succeeds depends on PKCE: req.session.verifier is also undefined, so a server that enforces PKCE refuses the code (no verifier), and one that doesn't would issue tokens for the attacker's account. That's the "PKCE also protects you" point — but don't rely on luck: reject missing values explicitly, as goodCheck does.

#OpenID Connect and ID tokens

OAuth gives the client an access token for an API, but it doesn't tell the client who the user is in any standard way. Everyone used to bolt that on differently ("call /me with the access token"). OpenID Connect (OIDC) is the standard layer on top: request the openid scope, and the token response also contains an ID token — always a JWT — that says "this user authenticated at this issuer, for this client, at this time".

The mental model: the access token is a key handed to your app to open someone else's API; the ID token is a letter of introduction addressed to your app, describing who just walked in. You don't show the letter to the API, and the key doesn't introduce anyone.

OIDC also standardises discovery (/.well-known/openid-configuration lists the endpoints and the jwks_uri), a userinfo endpoint, and standard claims like email, email_verified and name.

Validating an ID token is ordinary JWT verification with OIDC's rules: signature with the provider's published key, iss is the provider, aud is your client_id, not expired, and nonce matches the one you generated for this login attempt:

id-token.cjs
const { generateKeyPairSync, randomBytes } = require('node:crypto');
const jwt = require('jsonwebtoken');

// --- Pretend identity provider (in real life: Google, Okta, Entra ID …)
const idp = generateKeyPairSync('rsa', { modulusLength: 2048 });
const ISSUER = 'https://accounts.example.com';

function issueIdToken({ clientId, nonce }) {
  return jwt.sign(
    { sub: '110169484474386276334', email: 'rohit@example.com', email_verified: true, name: 'Rohit', nonce },
    idp.privateKey,
    { algorithm: 'RS256', issuer: ISSUER, audience: clientId, expiresIn: '1h', keyid: 'idp-key-1' },
  );
}

// --- Our app (the OIDC client)
const CLIENT_ID = 'my-app-client-id';
const nonce = randomBytes(16).toString('base64url'); // saved in the login session before redirecting

function validateIdToken(idToken, expectedNonce) {
  return jwt.verify(idToken, idp.publicKey, {
    algorithms: ['RS256'],
    issuer: ISSUER,
    audience: CLIENT_ID,   // must be issued to US, not to some other app
    nonce: expectedNonce,  // must belong to THIS login attempt
  });
}

const good = issueIdToken({ clientId: CLIENT_ID, nonce });
const claims = validateIdToken(good, nonce);
console.log({ iss: claims.iss, sub: claims.sub, aud: claims.aud, email: claims.email });
// { iss: 'https://accounts.example.com', sub: '110169484474386276334', aud: 'my-app-client-id', email: 'rohit@example.com' }

const forOtherApp = issueIdToken({ clientId: 'some-other-app', nonce });
const replayed = issueIdToken({ clientId: CLIENT_ID, nonce: 'nonce-from-an-old-login' });
for (const [label, token] of [['other app', forOtherApp], ['old nonce', replayed]]) {
  try { validateIdToken(token, nonce); } catch (err) { console.log(label, '→', err.message); }
}
// other app → jwt audience invalid. expected: my-app-client-id
// old nonce → jwt nonce invalid. expected: <this login's nonce>
What’s happening
  1. The "provider" signs with an RSA private key (RS256 is what most providers use); our app verifies with the public key — in real life fetched from the provider's jwks_uri and selected by kid, as in Key rotation with kid and JWKS.
  2. Before redirecting to the provider, the client generated a nonce and stored it with the login attempt; it sends it in the authorization request, and the provider copies it into the ID token.
  3. The good token passes all four checks. The user's identity in your system is the pair (iss, sub) — sub is the provider's stable, never-reassigned ID for the user. Don't key accounts on email: people change emails, and some providers let users set unverified ones.
  4. Other app: an ID token issued to a different client — say, one the attacker runs and the victim once signed into — is perfectly signed by the same provider, but aud is wrong → rejected. This is precisely why "any valid token from Google" is not a login.
  5. Old nonce: a validly signed token for our client, but from a different login attempt (replayed or injected) → jwt nonce invalid. jsonwebtoken 9 supports the nonce option directly.
AdvancedReference: ID token vs access token
ID tokenAccess token
Defined byOpenID ConnectOAuth 2.0
AudienceThe client (aud = your client_id)The resource server (API)
FormatAlways a JWTAnything — JWT or opaque
Client shouldValidate it and read the claimsTreat as opaque and send it to the API
Answers"Who logged in, when, how?""May the bearer call this API with these scopes?"
NeverSend it to APIs as an access tokenUse it as proof of login in your app

#Google sign-in with Passport

Passport is Express middleware for authentication built around strategies: passport-local for username and password, passport-google-oauth20 for Google, passport-jwt for Bearer tokens, and hundreds more. The strategy knows the protocol; you supply a verify callback that turns the provider's answer into your user. Passport then connects that user to the request as req.user.

With sessions, Passport needs two more functions. serializeUser decides what to store in the session after login (usually just the user's ID); deserializeUser turns that stored value back into a user object on every later request. The mental model: serialize writes the coat-check ticket number into the session; deserialize walks to the cloakroom each request and fetches the coat. That's how express-session and Passport "work in tandem", as my notes put it: express-session owns req.session; Passport stores its data under req.session.passport and fills req.user from it.

Here is my notes' Google example, corrected for passport 0.7, passport-google-oauth20 2.x and express-session 1.x. It can't run in the sandbox (none of those packages are installed, and it needs a registered Google OAuth client), so it's illustrative; it assumes a users data-access module, the environment variables named in it, and a redirect URI registered in Google Cloud Console that exactly matches callbackURL:

google-passport.cjs (illustrative)
const express = require('express');
const session = require('express-session');
const passport = require('passport');
const { Strategy: GoogleStrategy } = require('passport-google-oauth20');
const users = require('./users'); // your data access: findById, findOrCreateByGoogleId

const app = express();
app.set('trust proxy', 1); // TLS terminates at the load balancer

app.use(session({
  name: 'sid',
  secret: process.env.SESSION_SECRET,      // from the environment, never in code
  resave: false,
  saveUninitialized: false,
  // store: new RedisStore({ client }),    // the default MemoryStore is for development only
  cookie: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 8 * 60 * 60 * 1000 },
}));
app.use(passport.initialize());
app.use(passport.session());               // reads req.session.passport.user → deserializeUser → req.user

passport.use(new GoogleStrategy(
  {
    clientID: process.env.GOOGLE_CLIENT_ID,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    callbackURL: 'https://app.example.com/auth/google/callback',
    state: true,                           // generate + check `state` via the session
    pkce: true,                            // send a PKCE challenge too (requires state: true)
  },
  async (accessToken, refreshToken, profile, done) => {
    try {
      const user = await users.findOrCreateByGoogleId({
        googleId: profile.id,                // Google's stable `sub`
        email: profile.emails?.[0]?.value,
        name: profile.displayName,
      });
      done(null, user);                      // becomes req.user for this request
    } catch (err) {
      done(err);
    }
  },
));

passport.serializeUser((user, done) => done(null, user.id));   // store only the ID
passport.deserializeUser(async (id, done) => {
  try {
    done(null, (await users.findById(id)) ?? false);            // false = user gone, log out
  } catch (err) {
    done(err);
  }
});

app.get('/auth/google', passport.authenticate('google', { scope: ['profile', 'email'] }));

app.get('/auth/google/callback',
  passport.authenticate('google', { failureRedirect: '/login' }),
  (req, res) => res.redirect('/profile'),
);

app.get('/profile', (req, res) => {
  if (!req.isAuthenticated()) return res.redirect('/login');
  res.json({ id: req.user.id, name: req.user.name });          // JSON, not HTML built from user data
});

app.post('/logout', (req, res, next) => {                     // POST: a GET logout can be triggered by any <img>
  req.logout((err) => {                                       // callback required since Passport 0.6
    if (err) return next(err);
    res.redirect('/');
  });
});
What’s happening
  1. GET /auth/google: passport.authenticate('google', { scope }) doesn't authenticate anything yet — the strategy builds Google's authorization URL (client_id, redirect_uri from callbackURL, scope, response_type=code, plus state and code_challenge because of state: true, pkce: true), saves state and verifier in the session, and answers 302 to Google. This is step ③ of the diagram above.
  2. Google: the user signs in and consents; Google redirects to /auth/google/callback?code=…&state=….
  3. GET /auth/google/callback: the same passport.authenticate('google') now sees a code, checks state, POSTs the code (with the client secret and the verifier) to Google's token endpoint, then fetches the user's profile with the access token, and calls your verify callback with (accessToken, refreshToken, profile, done).
  4. Your verify callback maps Google's profile.id to a row in your database and calls done(null, user). Passport then calls req.login(user), which runs serializeUser → stores user.id at req.session.passport.user, and (since 0.6) regenerates the session ID to prevent session fixation. The final handler redirects to /profile.
  5. Every later request: express-session loads the session from the sid cookie; passport.session() finds passport.user, calls deserializeUser(id), and sets req.user to the fresh database row. req.isAuthenticated() is just "is req.user set?".
  6. Logout: req.logout(cb) removes passport.user from the session and regenerates it; you continue in the callback. It's a POST so a third-party page can't log users out with an image tag.
AdvancedCorrection: outdated Passport code
AdvancedPassport without sessions: issuing a JWT

Passport without sessions: issuing your own JWT

My notes say: "since JWT is being used, we are not using the serialize and deserialize methods". That works with session: false — Passport then sets req.user for this one request and doesn't touch the session, so serializeUser is never called. You issue your own token in the final handler:

google-passport-jwt.cjs (illustrative)
app.get('/auth/google/callback',
  passport.authenticate('google', { session: false, failureRedirect: '/login' }),
  (req, res) => {
    const token = jwt.sign({ sub: String(req.user.id) }, process.env.JWT_SECRET, {
      algorithm: 'HS256', expiresIn: '15m', issuer: 'my-app', audience: 'my-api',
    });
    res.cookie('access_token', token, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 15 * 60 * 1000 });
    res.redirect('/');
  },
);
What’s happening
  1. session: false tells Passport not to call req.login's session part: after your verify callback, req.user is set for this request only. Without it, and without serializeUser, Passport fails with a "failed to serialize user into session" error.
  2. The final handler turns your user (from your database, not Google's profile) into your JWT, signed with your key, for your API. Google's tokens have done their job — proving who the user is — and are not reused as your app's credentials.
  3. One catch: state: true / pkce: true keep the per-attempt state and verifier in the session during the redirect dance. A "session-less" app still needs express-session (or another state store) for those few minutes — just not for the logged-in user.
  4. Later requests carry the JWT; a passport-jwt strategy or your own middleware (like authenticate in Authentication vs authorization) verifies it and sets req.user.

My notes also had "clarification on where req.user.id comes from" — the answer is from whatever you passed to done. In the callback request it's the object from your verify callback's done(null, user); on later session requests it's whatever deserializeUser passed to its done; with JWTs it's whatever your token middleware assigns. If you call done(null, profile) with Google's raw profile, req.user.id is Google's user ID, not your database's — a common source of confusion.

And "learn more about express.json and passport": OAuth strategies don't need a body parser, because Google's callback is a GET with the code in the query string. passport-local does, because it reads req.body.username and req.body.password — add express.urlencoded() (forms) or express.json() before it.

Where does req.user come from?

With the session-based setup above, a logged-in user requests GET /profile. List, in order, what fills req.user.

Show answer

Cookie → session → passport.user → deserializeUser → req.user.

What’s happening
  1. The browser sends Cookie: sid=…. express-session verifies the cookie signature and loads the session from the store into req.session.
  2. passport.session() reads req.session.passport.user — the value serializeUser stored at login, here the user's ID.
  3. It calls deserializeUser(id, done), which loads the user from the database and passes it to done.
  4. Passport assigns that object to req.user, so /profile sees req.user.id and req.user.name from your database row. If deserializeUser passes false, Passport removes the stale login from the session and req.isAuthenticated() is false.

#Hashing passwords with bcrypt

You must never be able to read your users' passwords — not in the database, not in a backup, not in a log. So you don't store them; you store a hash: a one-way fingerprint. At login you hash the submitted password the same way and compare. If the database leaks, the attacker gets fingerprints, not passwords.

But ordinary hashes like SHA-256 are designed to be fast, and that's exactly wrong here: a GPU can try billions of guesses per second against a leaked SHA-256 hash. Password hashing functions — bcrypt, scrypt, Argon2 — are deliberately slow and salted. The salt (random bytes stored with the hash) makes every user's hash unique, so identical passwords look different and precomputed tables are useless. The work factor makes each guess cost real time.

The mental model: a hash is a meat grinder. Getting mince from a steak is easy; getting the steak back from mince is impossible. You check a password by grinding the candidate and comparing the mince. And a password hash is a grinder that is deliberately slow, so an attacker can only try a few guesses per second per core.

bcrypt-demo.cjs
const bcrypt = require('bcryptjs');

(async () => {
  const password = 'correct horse battery staple';

  const hash1 = await bcrypt.hash(password, 12);
  const hash2 = await bcrypt.hash(password, 12);
  console.log(hash1); // $2b$12$UyYmdm00vk0f7V5vLqu7P.CHrmn6wQdS53mkFVpy7kcqW09FLNhau
  console.log(hash2); // $2b$12$TZzr6lqX3e1ipigXhpmxUuzEQRVhTA5ZQdKyCYc4PJDcii6MVz1hu
  console.log(hash1 === hash2); // false

  console.log(await bcrypt.compare(password, hash1));                       // true
  console.log(await bcrypt.compare('Correct horse battery staple', hash1)); // false
  console.log(bcrypt.getRounds(hash1), bcrypt.getSalt(hash1));              // 12 $2b$12$UyYmdm00vk0f7V5vLqu7P.
})();
What’s happening
  1. bcrypt.hash(password, 12) generates a random 16-byte salt, then runs bcrypt with cost 12, meaning 2^12 = 4,096 rounds of its expensive key setup. Each extra point of cost doubles the work.
  2. The result is one self-describing 60-character string: $2b$ (bcrypt version), 12$ (cost), the next 22 characters are the salt, and the last 31 are the hash. Everything needed to check a password later is in that one column — no separate salt column.
  3. Hashing the same password twice gives different strings because each call picks a new salt. That's what stops an attacker spotting that two users share a password, or using a precomputed table.
  4. bcrypt.compare(candidate, hash) reads the cost and salt out of the stored hash, hashes the candidate with them, and compares the results. The right password → true; one capital letter different → false. You never "decrypt" anything.
  5. getRounds and getSalt just parse the string — useful for upgrading: when you raise the cost, check getRounds(hash) < 12 at login and rehash the password (which you have in plain text at that moment) with the new cost.
AdvancedMeasured: bcrypt cost vs time

How slow is slow? Measured with bcryptjs on the machine that built this site (yours will differ):

CostTime per hash
815 ms
1048 ms
12192 ms
13383 ms

Each step doubles. Pick the highest cost that keeps login comfortably fast on your production hardware (often around 100–300 ms), and revisit it as hardware improves.

AdvancedPitfall: bcryptjs on the event loop
AdvancedEdge case: the 72-byte limit
AdvancedBuilt-in alternative: scrypt

Node has two password-hashing functions built in, no package needed. scrypt is "memory-hard": each guess needs a large block of RAM, which makes GPU and custom-hardware attacks expensive:

scrypt-demo.cjs
const { scrypt, randomBytes, timingSafeEqual } = require('node:crypto');
const { promisify } = require('node:util');
const scryptAsync = promisify(scrypt);
const PARAMS = { N: 2 ** 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };

async function hashPassword(password) {
  const salt = randomBytes(16);
  const key = await scryptAsync(password.normalize('NFKC'), salt, 64, PARAMS);
  return `scrypt$${salt.toString('base64url')}$${key.toString('base64url')}`;
}

async function verifyPassword(password, stored) {
  const [, saltB64, keyB64] = stored.split('$');
  const expected = Buffer.from(keyB64, 'base64url');
  const actual = await scryptAsync(password.normalize('NFKC'), Buffer.from(saltB64, 'base64url'), expected.length, PARAMS);
  return timingSafeEqual(actual, expected);
}

(async () => {
  const stored = await hashPassword('correct horse battery staple');
  console.log(stored);  // scrypt$UTzATajnll0vUJow3mWThQ$Ys7XWUEjGH4Yr0jZBYchRZYLwsMbLZK3dA8Ng5DePkNON4tES4pvhDjJSzT8iXxPHZifZnqWx6ngkqTMWe7ZaQ  (~48 ms)
  console.log(await verifyPassword('correct horse battery staple', stored)); // true
  console.log(await verifyPassword('wrong', stored));                        // false
})();
What’s happening
  1. N: 2 ** 15 is the CPU/memory cost (doubling it doubles both), r: 8 the block size, p: 1 the parallelism. Memory needed is roughly 128 × N × r = 32 MiB, which hits Node's default maxmem limit (32 MiB), so we raise maxmem to 64 MiB — without it these parameters throw ERR_CRYPTO_INVALID_SCRYPT_PARAMS ("memory limit exceeded").
  2. hashPassword makes a 16-byte random salt and derives a 64-byte key. Like bcrypt, the stored string carries everything needed to verify: a label, the salt and the key (store the parameters too if you might change them later).
  3. password.normalize('NFKC') makes visually identical Unicode passwords (e.g. a precomposed "é" vs "e" plus a combining accent) hash the same, whichever keyboard typed them.
  4. verifyPassword splits the stored string, re-derives the key from the candidate with the same salt, and compares with timingSafeEqual, which takes the same time whether the first byte or the last one differs, so response timing leaks nothing.
  5. crypto.scrypt runs in the libuv thread pool: the event loop stays free while it works (unlike bcryptjs). The cost is that the default pool has only 4 threads, so many concurrent logins queue behind each other.
AdvancedBuilt-in alternative: Argon2

Recent Node versions (it's available on the Node 26 used here) also include Argon2, the winner of the Password Hashing Competition and the usual first recommendation for new systems:

argon2-demo.cjs
const { argon2, randomBytes } = require('node:crypto');
const { promisify } = require('node:util');
const argon2Async = promisify(argon2);

(async () => {
  const tag = await argon2Async('argon2id', {
    message: 'correct horse battery staple',
    nonce: randomBytes(16),   // the salt
    memory: 19456,            // KiB (19 MiB)
    passes: 2,                // iterations
    parallelism: 1,
    tagLength: 32,
  });
  console.log(tag.toString('base64url')); // GoRX7jAyLJax-HuZTFjvU2xms69sRW7h01ONlTk9QJk  (~14 ms; different every run)
})();
What’s happening
  1. 'argon2id' is the hybrid variant recommended for passwords: it resists both GPU cracking and side-channel attacks.
  2. nonce is Argon2's name for the salt. memory (in KiB), passes and parallelism are the cost knobs; 19 MiB with 2 passes is a commonly recommended minimum.
  3. The output is the raw derived tag. To use it like the scrypt example, store the salt and parameters alongside it, re-derive at login and compare with timingSafeEqual.
AdvancedLogin handlers that leak nothing

A login handler also needs to avoid telling attackers which part was wrong:

  • Answer "invalid email or password" for both an unknown email and a wrong password.
  • When the email doesn't exist, still run compare against a dummy hash, so the response takes as long as a real check and timing doesn't reveal which accounts exist.
  • Rate-limit login attempts per account and per IP (see Rate limiting), since even slow hashes can't stop an attacker trying the 1,000 most common passwords across every account.
Different hashes, same password

bcrypt.hash('hunter2', 12) run twice gives two different strings, yet bcrypt.compare('hunter2', either) is true for both. How?

Show answer

The salt is stored inside each hash string, and compare reuses it.

What’s happening
  1. Each hash call generates a new random salt, so the input to the bcrypt computation differs and so does the output: $2b$12$<salt A><hash A> vs $2b$12$<salt B><hash B>.
  2. compare('hunter2', storedA) parses storedA: cost 12, salt A. It runs bcrypt on 'hunter2' with cost 12 and salt A and gets hash A again — deterministic for a given salt.
  3. It compares that with the hash part of storedA → equal → true. Same for storedB with salt B.
  4. That's why you never compare by calling hash() again and checking string equality: a new call means a new salt, so it would never match.

Built from Rohit’s “Nodejs interview study” and “Nodejs-Interview-Chatgpt” docs. Examples are run on Node 26.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.