Security

How JWT Authentication Works: Structure, Signing, and Where It Breaks

· 3 min read

Every time you log into a modern web app, there is a decent chance a JSON Web Token changes hands. JWTs (RFC 7519) became the default session mechanism of the API era because they solve a real problem elegantly: how does a server know who you are on request number two, without looking you up in a database on every call? The answer is a signed, self-contained credential the client carries with it.

That elegance comes with sharp edges. The same properties that make JWTs convenient — readable, self-contained, verifiable offline — are behind a long list of production security incidents. Understanding the mechanics is the difference between using JWTs safely and shipping an authentication bypass.

The three segments

A JWT is three base64url-encoded parts joined by dots: header.payload.signature. The header declares the signing algorithm — {"alg":"HS256","typ":"JWT"} — and often a key ID (kid). The payload carries claims: registered ones like iss (issuer), sub (subject), aud (audience), exp (expiry), iat (issued at), plus whatever custom claims the issuer adds — roles, tenant IDs, email.

The critical fact most newcomers miss: base64url is encoding, not encryption. Anyone holding a JWT can read every claim in seconds — paste one into a decoder and see. The signature prevents modification, not reading. If a claim would be dangerous in an attacker's hands, it does not belong in a JWT payload.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9   ← header: {"alg":"HS256","typ":"JWT"}
.eyJzdWIiOiI0MiIsImV4cCI6MTc4MzE1MDIwMH0 ← payload: {"sub":"42","exp":1783150200}
.4mVCkxpZ0EKXBxAd_9rp-U6mkANpT1xkOTAvyVw ← signature (raw bytes, not decodable)

What the signature actually guarantees

The signature is computed over the exact bytes of header.payload using a key. With HS256, both signer and verifier share one secret (HMAC): anyone who can verify can also mint tokens, which is fine inside one service and dangerous across many. With RS256/ES256, the issuer signs with a private key and everyone else verifies with the public key — the right model when many services consume tokens from one identity provider.

Verification means recomputing the signature and comparing. Change one character of the payload and the signature no longer matches; without the key, an attacker cannot produce a valid signature for their forged payload. That is the entire security model — which is why every incident in the next section is some way of sidestepping that one check.

The five classic JWT vulnerabilities

JWT security history is short and repetitive. The same five mistakes account for most real-world breaks:

  • alg:none — early libraries accepted tokens whose header declared no signature. Attackers stripped the signature, set alg to none, and forged any identity. Modern libraries reject this, but only if you use their verify function correctly.
  • Decode-without-verify — calling decode() (which just base64-decodes) instead of verify() and trusting the claims. The token reads fine; it was never checked. Grep your codebase for jwt.decode and audit every hit.
  • HS256/RS256 confusion — a verifier configured for both algorithms could be tricked into using the RSA public key (which is public!) as an HMAC secret. Fix: pin the expected algorithm on the verify call.
  • Weak HMAC secrets — HS256 with a guessable secret is brute-forceable offline at GPU speed; tools like hashcat have JWT modes. Secrets should be 256 bits of real randomness, not a word.
  • Missing exp/aud checks — tokens without expiry live forever if stolen; tokens minted for service A replayed against service B succeed if B never checks aud.

Lifetimes, refresh, and revocation — the honest trade-off

The whole point of JWTs is that servers can verify them without a database hit. The corollary: a stolen JWT cannot be revoked without reintroducing that database hit. The standard compromise is short-lived access tokens (5–15 minutes) paired with long-lived refresh tokens that are stored server-side and can be revoked. Compromise window stays small; user experience stays smooth.

If your design needs instant, per-token revocation — logout must kill the session everywhere, now — then either keep a token denylist (accepting the lookup you tried to avoid) or reconsider whether classic server-side sessions fit better. JWTs are a tool with a specific shape, not a default.

A practical checklist

When reviewing JWT handling in a codebase, verify these in order: the verify function is called (not decode), with the algorithm pinned; exp, aud, and iss are validated; HS256 secrets are long and random, or asymmetric keys are used across service boundaries; access-token lifetime is minutes, not days; and no secret or personal data rides in the payload. Inspecting real tokens as you go is fastest with a local decoder — one that runs in your browser, so production tokens never touch someone else's server.

← All guides