Skip to main content
CodeOath
← All posts

Auth & Security63 min total · 19 parts

OAuth 2.0 and JWT Explained: What Really Happens When You Click "Sign In"

Part 11 of 19 · ~3 min

Validating a JWT Correctly

Getting a JWT to base64-decode cleanly tells you almost nothing — that's decoding, not validation, and the two get conflated constantly. A real check has to confirm every item below, not stop at "some signature matched something":

1. Signature — recompute it using the EXPECTED algorithm and key, never the algorithm
   the token itself claims (see the alg-confusion attack above) — reject on any mismatch.
2. exp — reject if the current time is at or past this token's expiration.
3. nbf — reject if the current time is before this token is allowed to be used.
4. iss — reject unless this token was issued by the authorization server you actually trust.
5. aud — reject unless this token was specifically intended for YOUR service — a token
   validly signed for a different audience must never be accepted just because the
   issuer happens to be one you trust.

Here's tasks-service's verifier, with every one of those five as an explicit, named option rather than implied behavior:

// tasks-service/auth/verifyAccessToken.js
import { createRemoteJWKSet, jwtVerify } from "jose";

const kestrelKeys = createRemoteJWKSet(
  new URL("https://id.kestrel.example/.well-known/jwks.json")
);

export async function verifyAccessToken(token) {
  const { payload } = await jwtVerify(token, kestrelKeys, {
    issuer: "https://id.kestrel.example",   // must have been issued by Kestrel specifically
    audience: "tasklight-tasks-api",         // must have been minted FOR this service
    algorithms: ["RS256"],                   // pinned — never read from the token
  });
  return payload; // signature, exp, and nbf were all already checked above this line
}

Skipping aud is the specific bug Tasklight actually shipped once, and it's worth walking through exactly how, because it's the kind of thing that passes code review. billing-service's first verifier looked almost identical to the one above, minus one line:

// billing-service/auth/verifyAccessToken.js — version 1, before anyone noticed
const { payload } = await jwtVerify(token, kestrelKeys, {
  issuer: "https://id.kestrel.example",
  algorithms: ["RS256"],
  // no `audience` check — any token Kestrel has ever signed gets through here
});

Both services trust Kestrel. Both correctly check the signature. Neither one is wrong about who issued the token. But a tasks:read-scoped access token — minted specifically for tasklight-tasks-api, never meant to touch billing at all — was still cryptographically valid, still signed by a trusted issuer, and billing-service's verifier had no way of knowing it wasn't meant for it. Anyone holding a perfectly ordinary tasks-scoped token could call billing-service's /usage endpoint and it would simply work. The fix was the one missing line above — audience: "tasklight-billing-api" — and it's the exact reason aud exists as a separate check from signature validity in the first place.

nbf gets skipped even more often than aud, mostly because it rarely does anything — most tokens are valid from the moment they're issued, so an unchecked nbf usually causes no visible problem at all. Kestrel actually sets it on one specific token type: when tasklight-digest-worker requests a client-credentials token for a run scheduled an hour from now, Kestrel backdates iat to the request time but sets nbf an hour later, matching the job it's meant for. A verifier that never checks nbf would accept that token immediately, an hour before it's supposed to be usable — invisible right up until something depends on the restriction actually holding, which is exactly the kind of gap that's cheap to close and expensive to debug in hindsight.