Authentication and Authorisation

Sessions against signed tokens, what a JWT can and cannot do, refresh rotation and revocation, and why ownership checks belong at the data.

Syntaxjwt.verify(token, publicKey, { algorithms: ['RS256'], issuer, audience }) // => claims, or throws

Authentication answers who is calling. Authorisation answers what may this caller do to this object. They fail differently and they live in different places, which is why an interviewer asks about them together and then checks whether you keep them apart.

Sessions against tokens

A session is an opaque random id in a cookie; the server looks it up in a store on every request. That costs a lookup, but revocation is a DELETE. A signed token — usually a JWT, defined by RFC 7519 — carries the claims inside the request, so any service can verify it with a public key and no round trip. The price is symmetric: nothing you do server-side can un-issue it before exp.

Refresh and revocation

The usual shape is a short access token, five to fifteen minutes, plus a long-lived refresh token in an HttpOnly cookie. RFC 9700, the OAuth 2.0 security best current practice published as BCP 240 in January 2025, requires refresh tokens for public clients to be either sender-constrained or rotated. Rotation only detects theft if replaying an already-used refresh token revokes the whole token family for that login. For faster revocation of access tokens, put a session id claim in the token and check a small self-expiring denylist in your cache.

Where the check belongs

A gateway can verify signatures, strip spoofed identity headers and reject expired tokens. It cannot know that invoice 91 belongs to tenant B. Ownership checks belong next to the data, ideally in the same query that reads it. A gateway rule listing URL prefixes is the thing that quietly stops matching your routes a year later.

Example

Example · javascript
// auth.js — authentication at the edge, authorisation at the resource.
const jwt = require('jsonwebtoken'); // jsonwebtoken 9.x

// Shorthand: options.expiresIn is sugar for writing the `exp` claim yourself.
function issueAccess(user) {
  return jwt.sign(
    { sub: user.id, tenant: user.tenantId, roles: user.roles },
    process.env.JWT_PRIVATE_KEY,
    { algorithm: 'RS256', issuer: 'api.example.com', audience: 'example-web', expiresIn: '10m' }
  );
}

// Longhand: the identical token. `exp` and `iat` are seconds since the epoch,
// not milliseconds — passing Date.now() here produces a token valid for 50,000 years.
function issueAccessLonghand(user) {
  const now = Math.floor(Date.now() / 1000);
  return jwt.sign(
    { sub: user.id, tenant: user.tenantId, roles: user.roles, iat: now, exp: now + 600 },
    process.env.JWT_PRIVATE_KEY,
    { algorithm: 'RS256', issuer: 'api.example.com', audience: 'example-web' }
  );
}

// AUTHENTICATION: who is calling. Pin algorithms, issuer and audience, or you
// accept tokens minted for a different service with a different key type.
function authenticate(req, res, next) {
  const [scheme, token] = (req.get('authorization') || '').split(' ');
  if (scheme !== 'Bearer' || !token) return res.sendStatus(401);
  try {
    req.user = jwt.verify(token, process.env.JWT_PUBLIC_KEY, {
      algorithms: ['RS256'],
      issuer: 'api.example.com',
      audience: 'example-web'
    });
    next();
  } catch {
    res.sendStatus(401); // expired, wrong audience, or forged — all the same answer
  }
}

// AUTHORISATION: this check cannot move upstream, because only the row knows
// who owns it. Same query, same transaction, no second trip.
app.get('/invoices/:id', authenticate, async (req, res) => {
  const invoice = await db.oneOrNone(
    'SELECT * FROM invoices WHERE id = $1 AND tenant_id = $2',
    [req.params.id, req.user.tenant]
  );
  // 404 rather than 403: a 403 confirms the id exists, which is itself a leak.
  if (!invoice) return res.sendStatus(404);
  res.json(invoice);
});

When to use it

  • Deciding between a session cookie and a JWT for a new API, and defending the choice in a design review.
  • Adding "sign out of all devices" to a product that already issues stateless access tokens.
  • Fixing a tenant-data leak caused by an authorisation rule that lived in the gateway instead of the query.
  • Specifying access and refresh token lifetimes, and what happens when a refresh token is replayed.

More examples

The stateful alternative: opaque session cookies

The same product requirement — sign out everywhere — is one line with sessions and an architectural change with stateless tokens. Reach for sessions when there is a single first-party API, a browser client, and a compliance requirement to terminate access on demand; reach for signed tokens when many services must authenticate a caller without a shared session store between them.

Example · javascript
// ioredis client. No claims in the cookie, so the store is consulted every
// request — and logout takes effect everywhere immediately.
const crypto = require('node:crypto');

async function login(res, user) {
  const sid = crypto.randomBytes(32).toString('base64url'); // 256 bits, unguessable
  await redis.set(`sess:${sid}`,
    JSON.stringify({ uid: user.id, tenant: user.tenantId }),
    'EX', 60 * 60 * 24 * 14);          // 14-day idle window, extended on use
  await redis.sadd(`user_sessions:${user.id}`, sid);
  res.cookie('sid', sid, {
    httpOnly: true,   // unreadable from JS, so an XSS bug cannot exfiltrate it
    secure: true,
    sameSite: 'lax',  // 'strict' breaks inbound links from email and Slack
    path: '/'
  });
}

async function logoutEverywhere(userId) {
  // With sessions, "sign out all devices" is a delete. With stateless JWTs there
  // is no equivalent: you wait for exp, or you add a denylist and become stateful.
  const sids = await redis.smembers(`user_sessions:${userId}`);
  if (sids.length) await redis.del(...sids.map(s => `sess:${s}`));
  await redis.del(`user_sessions:${userId}`);
}

Refresh token rotation with reuse detection

This is the answer to "how do you revoke a stolen credential in a stateless system". You accept that the access token is irrevocable for ten minutes and move all the control to the refresh exchange, which is stateful anyway. Note the honest cost: a mobile client that loses the response to a successful refresh will replay and get logged out, so the client must persist the new token before using it.

Example · javascript
// RFC 9700: refresh tokens for public clients are sender-constrained OR rotated.
// Rotation without replay detection is bookkeeping, not security.
// A refresh token belongs to a family — one login. Replay kills the family.
async function rotate(presented) {
  const hash = sha256(presented); // store hashes; a leaked DB must not yield live tokens
  const row = await db.oneOrNone(
    `SELECT id, family_id, user_id, used_at, revoked_at, expires_at
       FROM refresh_tokens WHERE token_hash = $1`, [hash]);

  if (!row || row.revoked_at || row.expires_at < new Date()) throw new AuthError('invalid');

  if (row.used_at) {
    // This token was already exchanged. Either the real client retried after a
    // dropped response, or a thief is using a copy. You cannot tell which, so
    // end the whole login and make the user sign in again.
    await db.none(
      'UPDATE refresh_tokens SET revoked_at = now() WHERE family_id = $1 AND revoked_at IS NULL',
      [row.family_id]);
    throw new AuthError('replayed');
  }

  const next = crypto.randomBytes(32).toString('base64url');
  await db.tx(async t => {
    await t.none('UPDATE refresh_tokens SET used_at = now() WHERE id = $1', [row.id]);
    await t.none(
      `INSERT INTO refresh_tokens (token_hash, family_id, user_id, expires_at)
       VALUES ($1, $2, $3, now() + interval '30 days')`,
      [sha256(next), row.family_id, row.user_id]);
  });
  return { access: issueAccess(await loadUser(row.user_id)), refresh: next };
}

Making short-lived JWTs revocable without a lookup per request

Use this when security asks for immediate revocation but you have already committed to tokens across many services. It is the middle position: one cache hit per request instead of a database session lookup, and a bounded window of zero instead of ten minutes. The design question it forces into the open is what the guard does when the cache is unavailable.

Example · javascript
// Tokens carry a session id. Only sessions that were explicitly killed are
// ever present in the cache, so the key set stays small.
function issueAccess(user, sid) {
  return jwt.sign({ sub: user.id, sid, tenant: user.tenantId },
    process.env.JWT_PRIVATE_KEY,
    { algorithm: 'RS256', expiresIn: '10m' });
}

const DENY_TTL = 15 * 60; // one access-token lifetime plus clock slack

async function revokeSession(sid) {
  // Self-expiring: after the longest a live token can survive, the entry is
  // pointless and removes itself. No cleanup job.
  await redis.set(`revoked:${sid}`, '1', 'EX', DENY_TTL);
}

async function guard(req, res, next) {
  let claims;
  try {
    claims = jwt.verify(bearer(req), process.env.JWT_PUBLIC_KEY, { algorithms: ['RS256'] });
  } catch { return res.sendStatus(401); }

  // Fail closed on a cache outage for an admin API; fail open for a read path.
  // Decide this deliberately and say which you chose.
  if (await redis.exists(`revoked:${claims.sid}`)) return res.sendStatus(401);

  req.user = claims;
  next();
}

Discussion

  • Be the first to comment on this lesson.