Signing and Verifying: HS256, RS256 and JWKS

Symmetric vs asymmetric signing, why the difference matters for who can mint tokens, and how key rotation works.

Two families of signing algorithm, and the choice determines who is able to create valid tokens.

HS256 — symmetric (HMAC-SHA256)

One shared secret signs and verifies. Simple and fast. The catch: every service that can verify can also mint. Give the secret to five services and any one of them — or anyone who compromises one — can issue a token for any user.

Fine when the issuer and the verifier are the same application.

RS256 / ES256 — asymmetric

A private key signs; the matching public key verifies. Verifiers hold only the public key, so a compromised API service cannot forge tokens. Publish the public key and any number of services — in any language, in any region — can verify independently.

This is what every identity provider uses, and it is the right default for anything beyond a single monolith.

JWKS: publishing keys

Issuers expose their public keys at a well-known URL as a JSON Web Key Set:

https://dfg.com/.well-known/jwks.json

Each key has a kid. The token's header names the kid it was signed with, so the verifier fetches the set (cached), picks the matching key, and verifies. Rotation becomes painless: publish the new key alongside the old, start signing with the new one, and retire the old after every token signed with it has expired.

Always cache JWKS — and always cap it

Fetching the key set on every request makes your identity provider a hard dependency of every API call. Cache it. But also bound the refresh: an unknown kid must not let an attacker trigger unlimited outbound fetches.

Never let the token choose

Verification must be told which algorithm to expect. Trusting the token's own alg header is the classic JWT vulnerability — covered next.

Example

Example · bash
# Generate an RSA key pair for RS256
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pem

# What a JWKS endpoint publishes (public halves only, never the private key)
curl https://dfg.com/.well-known/jwks.json
{
  "keys": [
    { "kty":"RSA", "kid":"k2", "use":"sig", "alg":"RS256",
      "n":"0vx7agoebGcQ...", "e":"AQAB" },
    { "kty":"RSA", "kid":"k1", "use":"sig", "alg":"RS256",
      "n":"pjdss8ZaDfEH...", "e":"AQAB" }
  ]
}
# Two keys live at once: k2 is signing new tokens, k1 still verifies old ones.

When to use it

  • Six microservices verify tokens issued by one auth service using its public key, so compromising a service does not let an attacker mint tokens.
  • A key rotation publishes k2 alongside k1, signs new tokens with k2, and drops k1 an hour later once every k1 token has expired.
  • A monolith that issues and verifies its own tokens stays on HS256 because there is no second party to distribute a public key to.

More examples

Verifying against a cached JWKS

Every option here is doing work: pinned algorithms, a checked issuer, a checked audience, and a small clock tolerance so a server two seconds ahead does not reject fresh tokens.

Example · javascript
import jwt from 'jsonwebtoken';
import { createRemoteJWKSet, jwtVerify } from 'jose';

// jose caches the key set and refreshes it on an unknown kid, with a cooldown
// so a bogus kid cannot be used to hammer the issuer.
const JWKS = createRemoteJWKSet(
  new URL('https://dfg.com/.well-known/jwks.json'),
  { cooldownDuration: 30_000, cacheMaxAge: 600_000 },
);

export async function verifyAccessToken(token) {
  const { payload } = await jwtVerify(token, JWKS, {
    algorithms: ['RS256'],                 // pinned — never read from the token
    issuer: 'https://dfg.com',
    audience: 'https://dfg.com/api',
    clockTolerance: 5,                     // seconds of allowed clock skew
  });
  return payload;
}

export async function bearerAuth(req, res, next) {
  const token = (req.get('authorization') || '').replace(/^Bearer /i, '');
  if (!token) return unauthorized(res, 'authentication_required');
  try {
    const claims = await verifyAccessToken(token);
    req.user = { id: claims.sub, scopes: String(claims.scope || '').split(' ') };
    next();
  } catch (err) {
    return unauthorized(res, err.code === 'ERR_JWT_EXPIRED' ? 'token_expired' : 'invalid_token');
  }
}

function unauthorized(res, error) {
  res.set('WWW-Authenticate', `Bearer realm="api", error="${error}"`);
  return res.status(401).json({ error });
}

Signing with a rotating key

Publish before you sign, and remove only after the longest possible token lifetime has passed. Reversing either step causes a window of rejected valid tokens.

Example · javascript
import fs from 'fs';
import jwt from 'jsonwebtoken';

// Two keys live at once. Sign with the newest; publish both for verification.
const KEYS = {
  k1: { pem: fs.readFileSync('keys/k1-private.pem'), retiredAt: '2026-08-04' },
  k2: { pem: fs.readFileSync('keys/k2-private.pem'), retiredAt: null },
};
const ACTIVE_KID = 'k2';

export function issueAccessToken(user) {
  return jwt.sign(
    { sub: String(user.id), scope: user.scopes.join(' ') },
    KEYS[ACTIVE_KID].pem,
    {
      algorithm: 'RS256',
      keyid: ACTIVE_KID,                   // → the "kid" header the verifier reads
      expiresIn: '10m',
      issuer: 'https://dfg.com',
      audience: 'https://dfg.com/api',
      jwtid: crypto.randomUUID(),          // enables a denylist if you need one
    },
  );
}

// Rotation timeline:
//   T+0    publish k2 in JWKS (verify only)
//   T+10m  switch ACTIVE_KID to k2
//   T+20m  every k1 token has expired → remove k1 from JWKS
// Skipping the first step means clients reject tokens they have not seen a key for.

Discussion

  • Be the first to comment on this lesson.