JWTs show up everywhere — API auth, OAuth flows, session replacements, password reset links — yet a surprising number of developers treat them as opaque magic strings. They aren't. A JWT is a transparent, self-describing data structure with one job: let a recipient trust a small blob of JSON without calling back to the issuer. Understanding the mechanics is the difference between using JWTs safely and shipping a token-shaped vulnerability.
What a JWT actually is
A JWT is three base64url-encoded chunks joined by dots:
textxxxxx.yyyyy.zzzzz header.payload.signature
Here's a real (truncated) example:
texteyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSIsIm5hbWUiOiJBZGEiLCJleHAiOjE3MzU2ODk2MDB9.SflKxw_AdQssw5c
Notice there's no encryption happening. Each segment is just encoded — not hidden. Anyone holding this token can read the header and payload. That single fact drives most of the rest of this article.
base64url, not base64
The segments use base64url encoding, a URL-safe variant of base64. It swaps
+/-_=+/base64url is encoding, not encryption — it's reversible by anyone, with no key. You can paste a token into a JWT decoder and read every claim instantly, or run a segment through a plain base64 decoder to see the raw JSON. That transparency is by design.
The header
Decode the first segment and you get something like:
json{ "alg": "HS256", "typ": "JWT" }
- — the algorithm used to produce the signature (e.g.text
alg,textHS256,textRS256).textES256 - — the token type, usuallytext
typ.textJWT - — an optional key ID telling the verifier which key to use when the issuer rotates keys.text
kid
The header is also where one of the oldest JWT attacks lives, which we'll get to.
The payload and its claims
The middle segment holds the claims — statements about an entity (usually a user) plus metadata. Decoded:
json{ "sub": "12345", "name": "Ada", "role": "admin", "iss": "https://auth.example.com", "aud": "https://api.example.com", "iat": 1735603200, "exp": 1735689600 }
The spec defines a set of registered claims — short, standardized keys with agreed meaning:
- — issuer (who minted the token)text
iss - — subject (who the token is about)text
sub - — audience (who the token is for)text
aud - — expiration time (Unix seconds)text
exp - — not-before timetext
nbf - — issued-at timetext
iat - — a unique token ID, useful for revocation liststext
jti
Everything else is a public or private claim — your own application data like
rolenameBecause the payload is readable by anyone, never put secrets in it. No passwords, no API keys, no personal data you wouldn't print on a postcard. A JWT proves the data hasn't been tampered with; it does nothing to keep the data private.
The signature: where trust comes from
The third segment is what makes a JWT trustworthy. The signature is computed over the encoded header and payload:
textsignature = HMAC_SHA256( base64url(header) + "." + base64url(payload), secret )
If an attacker edits the payload — say, flips
"role": "user""role": "admin"This is the core promise of a JWT: integrity and authenticity, not confidentiality.
Symmetric vs asymmetric signing
There are two families of signing algorithms:
- HMAC (HS256/384/512) — symmetric. The same secret signs and verifies. Simple, fast, fine when one party both issues and validates tokens. The catch: every service that verifies tokens needs the secret, and the secret can also forge tokens.
- RSA / ECDSA (RS256, ES256, etc.) — asymmetric. A private key signs; a public key verifies. The auth server keeps the private key; every API can verify with the freely-distributable public key and can't mint tokens. This is the right choice for distributed systems and anything third parties consume.
As a rule: if more than one independent service verifies your tokens, prefer asymmetric signing.
Signing is not encryption
This trips people up constantly. A standard signed JWT (a JWS) is signed but not encrypted — fully readable. If you genuinely need the payload to be confidential in transit or at rest, you want JWE (JSON Web Encryption), a separate construction with five segments that actually encrypts the content.
In practice, most teams don't need JWE. They use a signed JWT over TLS and simply keep sensitive data out of the payload. Reach for JWE only when you have a hard requirement to hide claim contents from the holder.
How verification actually works
Verifying a JWT is more than checking the signature. A correct verifier does all of the following, and rejects the token if any step fails:
- Parse and split the token into its three segments.
- Inspect the header and pin the expected algorithm — don't blindly trust .text
alg - Recompute the signature with the correct key and compare using a constant-time comparison.
- Check — reject if the current time is past expiry (allowing a small clock-skew leeway, e.g. 30–60 seconds).text
exp - Check — reject if the token isn't valid yet.text
nbf - Check — confirm the issuer is one you trust.text
iss - Check — confirm the token was minted for your service, not a different one.text
aud
Skipping
audissExpiry, refresh, and revocation
JWTs are typically stateless — the server doesn't store them, it just verifies the signature on each request. That's the performance win, and also the hard part: a stateless token can't easily be revoked before it expires.
The standard pattern:
- Issue a short-lived access token (minutes to an hour) carrying the claims APIs need.
- Issue a long-lived refresh token, stored server-side, used only to mint new access tokens.
- When a user logs out or is banned, invalidate the refresh token. The access token still works until — which is why you keeptext
expshort.textexp
If you need instant revocation of access tokens, you've partly given up statelessness: maintain a denylist of
jtiDecoding is not verifying
This is the single most important takeaway. Reading a JWT and trusting a JWT are completely different operations.
Decoding a JWT requires no key and proves nothing. Anyone can base64url-decode the payload, edit
"role": "admin"js// DANGER: this trusts attacker-controlled data const payload = JSON.parse(atob(token.split('.')[1])); if (payload.role === 'admin') grantAccess(); // forgeable // CORRECT: verify the signature first, then read claims const claims = verifyJwt(token, publicKey, { algorithms: ['RS256'], issuer: 'https://auth.example.com', audience: 'https://api.example.com', }); if (claims.role === 'admin') grantAccess();
Use a decoder to inspect and debug tokens — that's exactly what the JWT decoder is for. Never use raw decoding as an authorization decision in production code.
Common security mistakes
The alg: none
"alg": "none"algnoneAlgorithm confusion (RS256 → HS256). A server expects RS256 (asymmetric) and verifies with the RSA public key. An attacker changes
algWeak HMAC secrets. HS256 with a short, guessable secret is brute-forceable offline. Fix: use a high-entropy secret (32+ random bytes), or switch to asymmetric keys.
Skipping expaudiss
Putting secrets in the payload. It's readable. Don't.
Storing tokens carelessly in the browser. A JWT in
localStorageHttpOnlySecureSameSiteThe mental model to keep
A JWT is a tamper-evident envelope, not a locked box. It guarantees that claims came from someone holding the signing key and haven't been altered — nothing more. Keep secrets out of the payload, pin your algorithm, validate expiry and audience, and burn this line into memory: decoding is free and trust is earned only through verification. Get those right and JWTs are a clean, scalable way to carry identity. Get them wrong and you've handed users an editable permission slip.