Skip to main content
JWT (JSON Web Token)lesson 2 of 4 · 3 min read

Signing and Verification

Shared secret or key pair

Two families of algorithm dominate, and choosing between them looks like a cryptography detail while actually being an architecture decision.

One uses a single shared secret that both signs and verifies. The other is asymmetric: a private key signs, and anyone holding the matching public key can verify but never forge.

See what the shared secret costs you. Every service that verifies tokens holds it, and anything that can verify can also mint. One leaked environment variable anywhere in your fleet lets an attacker issue themselves admin tokens.

With the asymmetric family, your private key lives only in the auth service and everything else holds a key that is public by definition.

Distribute those public keys the solved way. Your auth server publishes its current keys at a well-known address, each tagged with an identifier that tokens reference in their header.

Verifiers fetch and cache the set, and rotation becomes routine. Publish the new key, start signing with it, and retire the old one once the last tokens signed with it have expired. Large identity providers rotate on the order of days and no client notices.

The checks people skip

Do the verification locally, because that is the entire point. Check the signature, then check the claims.

Watch the second half, because that is where implementations get lazy. Enforce the expiry, with a minute or two of tolerance for clocks that disagree. Check that the audience is your service, or a token legitimately minted for some other API replays cleanly against yours. Check the issuer is one you actually trust.

Hold onto why this became the default for APIs. No session store in the request path, any stateless replica can authenticate any request, and your auth service going down stops new logins without touching traffic already in flight.

the shape of it
ClientAuth serviceprivate key in KMSOrders APIverifies locallyJWKS endpointpublic keys by kid1. login2. signed JWT3. Bearer JWT4. cache the keys
step 1 of 4
Only the auth service can sign; every other service verifies with cached public keys and no per-request network call.
the checks people skip, and the one that gets exploited
Java
Claims verify(String token) {
  // Pin the algorithm. The header is attacker-controlled input, so
  // letting the token choose means running the attacker's instructions.
  // This is the "alg: none" bug, and its cousin where a token signed
  // with your published public key as an HMAC secret is accepted.
  Claims c = Jwts.parser()
      .verifyWith(publicKey)
      .sig().only(Jwts.SIG.RS256)        // never trust header.alg
      .build()
      .parseSignedClaims(token)
      .getPayload();

  // A valid signature on somebody else's token is still an attack.
  if (!"https://auth.example.com".equals(c.getIssuer())) throw new Denied();
  if (!c.getAudience().contains("orders-api")) throw new Denied();
  // Expiry is enforced by the parser, with a little clock skew allowed.
  return c;
}

Worked example

Lena joins a logistics company running 14 microservices that all verify HS256 tokens with one shared secret. During onboarding she finds that secret in three places: a Kubernetes secret, a CI variable, and, unpleasantly, a docker-compose.yml in a repo that had been public on GitHub for six months. Anyone who cloned it could mint a token with role admin and every service would accept it. Rotating an HMAC secret across 14 services without downtime turns out to be a coordinated dance nobody wants to repeat, so the team migrates to RS256: the auth service keeps its private key in KMS, and services fetch public keys from a JWKS endpoint with a 10-minute cache. The emergency rotation took 45 tense minutes; the next one, a year later under RS256, was a non-event nobody outside the auth team noticed.