You've seen this string a thousand times:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
You know it's a JWT. You know it sits in Authorization: Bearer …. You know it does something with auth. But if I asked you what the three dot-separated parts actually contain — and whether your service can trust any of them — could you answer without Googling?
Most developers can't, and that gap is where a surprising number of production security bugs live. This post is the walk-through I wish I'd had three years ago. Real token, real decode, the security claims that are true, and the ones that aren't.
A JWT in three parts
Every JSON Web Token has the form <header>.<payload>.<signature>, separated by dots. Each segment is base64url-encoded — note the url part; it's not the same as regular base64, which is why naive atob() calls sometimes blow up.
Take the token above and split on the dots:
Part 1 (header): eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Part 2 (payload): eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
Part 3 (signature): SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Decode the first two from base64url and you get plain JSON. Decode the third and you get raw bytes — that's not text; it's a cryptographic hash. Let's walk through what each part means.
Part 1: the header
Decoded, it looks like this:
{
"alg": "HS256",
"typ": "JWT"
}
Two fields almost always:
-
alg— the algorithm used to sign the token. Common values:HS256(symmetric, shared secret),RS256(asymmetric, public/private key),ES256(elliptic curve), and the infamousnone(no signature — more on this in a minute). -
typ— almost always"JWT". Sometimes"JWE"for encrypted tokens, but those are rare in the wild.
That's it. The header is metadata about the token itself, not about the user. There's no auth information here. Don't put user data in the header. I've seen people do it; it's wrong.
Part 2: the payload (a.k.a. claims)
This is where the actual data lives:
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
The payload is a set of claims — statements about the user (or whatever the token represents). Some claim names are standardized (called "registered claims" in RFC 7519):
-
sub— subject. The user ID, typically. -
iat— issued-at. Unix timestamp of when the token was created. -
exp— expiration. Unix timestamp of when the token stops being valid. -
iss— issuer. Who minted the token (your auth service URL). -
aud— audience. Who the token is intended for. -
nbf— not-before. Token isn't valid until this timestamp. -
jti— JWT ID. Unique identifier, useful for revocation.
Anything else (name, email, roles, tenant_id, custom stuff) is a private claim. You can put whatever you want.
But — and this is the part that trips up a startling number of senior engineers — the payload is base64-encoded, not encrypted. Anyone holding the token can decode it. The user can. An attacker who steals the token can. You can, right now, by pasting it into a JWT decoder.
Don't put secrets in JWT payloads.
Part 3: the signature
This is where the security actually lives. The signature is computed roughly like this (for HS256):
HMAC-SHA256(
base64url(header) + "." + base64url(payload),
secret_key
)
The signature is a cryptographic proof that whoever has the secret key signed the exact bytes of the header and payload you see. If you change a single character in the payload, the signature stops matching, and any verifier (server, gateway, whatever) will reject the token.
This is the only thing protecting JWTs from forgery. Not the encoding. Not obscurity. Just this hash and whoever holds the key.
Three myths developers believe
I have heard each of these in real code reviews, from real engineers, more times than I'd like.
Myth 1: "JWTs are encrypted"
No. They're signed. Signed and encrypted are different.
- Signed (JWS): anyone can read it; only the holder of the secret can produce a valid one.
- Encrypted (JWE): only the recipient with the key can read it.
99% of JWTs in the wild are JWS. If you're using jsonwebtoken in Node, pyjwt in Python, or any standard JWT library out of the box, you're producing JWS tokens. Treat the payload as public.
Myth 2: "JWTs are stateless and that's strictly better than sessions"
The marketing for JWTs leans hard on "no server-side state, no session table, infinitely scalable!" That's true, but it costs you:
-
No revocation. With a session table, you delete the row to log someone out. With a stateless JWT, you can't — it's valid until
expno matter what. Either you accept that, build a denylist (which adds back the state you tried to remove), or use very short-lived tokens with refresh tokens. - No rotation on suspicious activity. Same problem. The token is valid until it expires.
- Larger requests. A JWT carrying user info plus roles plus tenant data is 800–1200 bytes per request. A session ID is 32. At scale, this compounds.
Neither pattern is universally correct. The tradeoff is real.
Myth 3: "The alg field tells me how to verify"
This is the most dangerous one. Years ago, someone realized that if your verification code does this:
const decoded = jwt.verify(token, secret, { algorithms: [decodedHeader.alg] })
…then an attacker can flip the header to {"alg": "none"}, omit the signature entirely, and the library happily verifies it as valid. There's also the famous HS256/RS256 confusion attack: a public key meant to verify RS256 tokens gets passed as the secret for HS256, and the attacker forges tokens using that public key.
The fix: never read alg from the token. Hardcode the algorithms you accept on the verify side:
jwt.verify(token, secret, { algorithms: ['HS256'] })
Most modern libraries default to safe behavior now, but library version matters. Audit it.
Decoding in practice
Three ways to look at a token's contents:
1. Browser tool — fastest for one-off debugging. The JWT decoder on json.renderlog.in decodes locally — the token never leaves your browser, which matters when you're debugging a real production token that, if pasted into a less careful tool, would end up in someone's logs.
2. Command line:
echo "eyJhbGc...payload..." | cut -d. -f2 | base64 -d | jq
Two gotchas: base64 -d doesn't handle base64url's - and _ characters or missing padding. The robust version:
decode_jwt() {
local payload=$(echo $1 | cut -d. -f2)
payload=${payload//-/+}
payload=${payload//_/\/}
while ((${#payload} % 4)); do payload+="="; done
echo $payload | base64 -d | jq
}
3. Inline in a Node REPL:
const [, payload] = token.split('.')
console.log(JSON.parse(Buffer.from(payload, 'base64url').toString()))
The 'base64url' encoding label was added in Node 16. Before that you needed to do the dash/underscore swap manually.
The five JWT mistakes I see most often
1. Storing tokens in localStorage. Any XSS vulnerability becomes a token-stealer. Use httpOnly cookies instead. Yes, that means dealing with CSRF — that's a different, more solvable problem.
2. Long-lived access tokens with no rotation. If your access tokens last 30 days, an attacker who steals one has a 30-day window. Use 15-minute access tokens with refresh tokens; rotate refresh tokens on every use.
3. Putting PII in the payload. Email, full name, phone — all readable by anyone who intercepts the token. If the payload needs personal data, use opaque IDs and look them up server-side.
4. Reusing one secret across services. If service A's secret leaks, every service that trusts tokens signed with it is now compromised. Use distinct keys per issuer; for distributed systems, use RS256 with public-key distribution.
5. Skipping exp validation. It's the library's job, but I've seen homegrown verifiers ("we just need to decode it") that forget this. A token without expiration validation is a permanent skeleton key.
When NOT to use JWTs
Despite the hype, JWTs aren't always the answer:
- Standard web app with login: session cookies are simpler, smaller, and easier to revoke. JWTs add complexity for no real benefit.
- Long-lived auth (months/years): the no-revocation problem becomes serious. Use sessions or paseto-style tokens with built-in revocation hooks.
- Storing user state: that's what a database is for.
JWTs shine for: stateless API authentication, machine-to-machine auth, federation across services, and signed claims passed between systems that don't share a database.
Related tools that pair with JWT debugging
When you're elbow-deep in auth issues, a few other browser utilities show up in the same workflow:
- JSON formatter — for pretty-printing the decoded payload when it's nested.
- JSON validator — when a custom claim has invalid JSON nested in it.
- Base64 encoder/decoder — for decoding the parts manually if you want to verify what the JWT decoder is doing.
- JSON diff — for comparing two tokens issued at different times to spot what changed in the claims.
All run client-side. None of them upload your tokens anywhere.
TL;DR
- A JWT is
<header>.<payload>.<signature>, all base64url-encoded. - The payload is readable, not encrypted. Don't put secrets there.
- The signature is the only thing protecting against forgery — keep secrets safe and never trust the
algfield. - The "stateless" advantage of JWTs comes with a real revocation cost. Account for it.
- For day-to-day debugging, a browser-based JWT decoder is faster and safer than pasting tokens into random sites.
The next time you see one of these tokens in an Authorization header, you'll know exactly what your server is being asked to trust.
If this was useful, I've also built a handful of other free, browser-based tools — no signup, no uploads, everything runs client-side:
- JSON Tools — https://json.renderlog.in (formatter, validator, JWT decoder, JSONPath tester, 40+ converters)
- Text Tools — https://text.renderlog.in (case converters, slug generator, HTML/markdown utilities, 70+ tools)
- PDF Tools — https://pdftools.renderlog.in (merge, split, OCR, compress to exact size, 40+ tools)
- Image Tools — https://imagetools.renderlog.in (compress, convert, resize, background remover, 50+ tools)
- QR Tools — https://qrtools.renderlog.in (WiFi, vCard, UPI, bulk QR codes with logos)
- Calc Tools — https://calctool.renderlog.in (60+ calculators for finance, health, math, dates)
- Notepad — https://notepad.renderlog.in (private, offline-first notes, no signup)
Top comments (0)