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 9 of 19 · ~3 min

What's Actually Inside a JWT

Strip a JSON Web Token down to what it actually is, and there's nothing mysterious about it: a signed, compact package for a handful of claims — small facts — describing a user or a grant. Pull one apart and you'll find exactly three chunks of base64url text glued together with dots: header.payload.signature. Here's one Kestrel actually issues, an access token scoped to tasks:read:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJrZXNfOWYzYTJiIiwic2NvcGUiOiJ0YXNrczpyZWFkIiwiZXhwIjoxNzMyNTU0MDAwfQ.k3n2...
└──────────────── header ────────────────┘ └──────────────────── payload ─────────────────────┘ └ signature ┘

Decode that first segment and you get exactly this — nothing more, nothing hidden:

{ "alg": "HS256", "typ": "JWT" }

The second segment is just JSON claims, and again, exactly what's shown here is exactly what's encoded above it:

{ "sub": "kes_9f3a2b", "scope": "tasks:read", "exp": 1732554000 }

Worth a beat on that word "base64url" specifically, since it's easy to skim past. It's not quite the same alphabet as the base64 you'd get from a general-purpose encoder — standard base64 uses + and /, both of which mean something specific inside a URL, and pads its output with trailing = signs. A JWT is routinely carried in a URL — in the redirect from step 4 of the code flow, in a query string, in a header value — so its three segments use a URL-safe alphabet (- and _ instead of + and /) with the padding dropped entirely. Feed a JWT through a plain base64 decoder without accounting for that and it'll fail on tokens that happen to need the swapped characters, which is exactly why the code samples earlier in this reference decode cleanly and a naive atob() call on a real-world token sometimes doesn't.

A small set of these claim names shows up so consistently, across every issuer, that it's worth memorizing rather than looking up each time:

ClaimMeaning
subSubject — who or what this token is about
issIssuer — who created and signed it
audAudience — who it's meant for
expExpiration (Unix timestamp) — invalid at or after this moment
iatIssued at
nbfNot before — not valid until this moment

Two things about this catch people out, and neither is academic.

  • Signing and encrypting are not the same operation, and a JWT only ever gets the first one. Base64 isn't a cipher — it's just a text-safe way to represent bytes, and anyone at all, Priya included, can reverse it on this token in about one line of console code and read sub, scope, and exp sitting there in the open. What the signature actually buys is tamper-evidence: proof that whatever's in that payload is exactly what Kestrel put there, not proof that nobody else can see it. That distinction only matters once you start deciding what's allowed to live inside a token. sub, scope, exp — fine, none of them are worth hiding. A billing card number or an internal database key would not be fine, because a JWT payload is closer to a postcard than a sealed envelope.
  • The exp claim is doing more work than its size suggests. There's no equivalent of deleting a row to kill a JWT early — once it's out, it stays good for exactly as long as exp says, whether or not Kestrel would still approve of it a minute later. Suspend Priya's account the instant after minting one of these, and this specific token doesn't care; it keeps working until the clock catches up to it. That's precisely why Kestrel never hands out a long-lived one of these directly — fifteen minutes is the ceiling — and leans instead on a refresh token, kept where it can actually be pulled back, to mint fresh ones without making Priya type her password again. How that pairing actually works is where the next few chapters are headed.