Tokens and JWT

A JWT is a signed, readable JSON payload you can verify without a database lookup — and cannot withdraw early.

Syntaxconst token = jwt.sign({ sub: userId }, process.env.JWT_SECRET, { algorithm: 'HS256', expiresIn: '15m' })

A session cookie makes the server look up an ID on every request. A JSON Web Token avoids that lookup. The claims travel inside the token, signed, so any request can be checked with the key alone.

A JWT is three base64url strings joined by dots: a header naming the algorithm, a payload of JSON claims, and a signature over the first two. base64url is encoding, not encryption. Anyone holding the token can read the payload. The signature only proves nobody altered it.

Signing and verifying

HS256 is an HMAC: one shared secret both signs and verifies, which is fine when the same service does both. RS256 signs with a private key and verifies with a public one, so other services can check tokens without being able to mint them. With the jsonwebtoken package that is jwt.sign(payload, secret, options) and jwt.verify(token, secret, options). Always pass algorithms to verify; leave it out and you trust whatever the token's own header claims.

Expiry

The exp claim is seconds since 1970. jwt.verify throws TokenExpiredError past it. { expiresIn: '15m' } is shorthand: the library writes exp as the current time plus 900 seconds, exactly as exp: Math.floor(Date.now() / 1000) + 900 would. Both produce the same token.

What a token must never carry

No passwords, no API keys, no card or health data — nothing you would not print. Nothing you need to withdraw either, because there is no record of an issued token to delete. Keep expiry short and pair it with a refresh token you store and can revoke.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Authenticating requests to a stateless API from a mobile app or single-page front end.
  • Letting several services verify the same login without sharing a session store.
  • Giving a download link a signed, short-lived token instead of a guessable URL.
  • Adding revocation to a token-based login that currently has none.

More examples

Verifying on every request, and handling each failure

The function that sits in front of every protected route. Two details carry most of the weight: pinning algorithms, and telling an expired token apart from an invalid one so the client knows whether retrying is worth anything. Pinning issuer and audience stops a token minted for one of your services being replayed at another.

Example · javascript
const jwt = require('jsonwebtoken');

function authenticate(req, res, next) {
  // The Bearer scheme: "Authorization: Bearer eyJhbGci..."
  const header = req.headers.authorization ?? '';
  const token = header.startsWith('Bearer ') ? header.slice(7) : null;
  if (!token) return res.status(401).json({ error: 'missing token' });

  try {
    // algorithms is NOT optional in practice. Without it, a token whose
    // header says alg: "none" is a token you accept.
    const claims = jwt.verify(token, process.env.JWT_SECRET, {
      algorithms: ['HS256'],
      issuer: 'api.example.com',
      audience: 'web',
    });
    req.user = { id: claims.sub };
    next();
  } catch (err) {
    // Expired is a different answer from forged: the client should
    // refresh in the first case and give up in the second.
    if (err instanceof jwt.TokenExpiredError) {
      return res.status(401).json({ error: 'token expired' });
    }
    if (err instanceof jwt.JsonWebTokenError) {
      return res.status(401).json({ error: 'invalid token' });
    }
    next(err);
  }
}

A short access token plus a revocable refresh token

How to get revocation back after choosing tokens over sessions. The lookup happens once every fifteen minutes on refresh rather than on every request, which is the whole point of the token. Storing only the SHA-256 of the refresh token means a leaked database still cannot be used to log in, the same reasoning as hashing a password.

Example · javascript
const jwt = require('jsonwebtoken');
const { randomBytes, createHash } = require('node:crypto');

async function issueTokens(userId) {
  // Short-lived, never stored, never revocable. Fifteen minutes is the
  // longest a stolen one stays useful.
  const accessToken = jwt.sign({ sub: userId }, process.env.JWT_SECRET, {
    algorithm: 'HS256',
    expiresIn: '15m',
    issuer: 'api.example.com',
    audience: 'web',
  });

  // Long-lived and NOT a JWT: an opaque random string whose hash lives in
  // a table. Deleting the row revokes it, which no JWT can offer.
  const refreshToken = randomBytes(32).toString('base64url');
  const digest = createHash('sha256').update(refreshToken).digest('hex');
  await pool.query(
    "INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, now() + interval '30 days')",
    [userId, digest],
  );

  return { accessToken, refreshToken };
}

RS256, so other services can verify but not issue

Reach for RS256 the moment more than one service needs to check the same token. With HS256 every verifier holds the signing secret, so any one of them can issue tokens for any user. Here the signing power stays in one place, and rotating the public key is a config change rather than a trust problem.

Example · javascript
const { generateKeyPairSync } = require('node:crypto');
const jwt = require('jsonwebtoken');

// Run once; keep privateKey secret and publish publicKey.
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
  modulusLength: 2048,
  privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
  publicKeyEncoding: { type: 'spki', format: 'pem' },
});

// Only the auth service holds the private key, so only it can mint tokens.
const token = jwt.sign({ sub: 'user_42' }, privateKey, {
  algorithm: 'RS256',
  expiresIn: '15m',
});

// Every other service holds just the public key. A leak there lets an
// attacker READ tokens, not create them.
const claims = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
console.log(claims.sub);

Discussion

  • Be the first to comment on this lesson.