JSON Web Tokens (JWTs) power authentication for countless APIs, SPAs, and microservices. They travel in Authorization: Bearer headers, hide inside cookies, and show up in every OAuth 2.0 / OpenID Connect flow. They look like opaque secrets — but JWTs are signed, not encrypted, by default. Anyone holding the string can Base64url-decode the header and payload. This guide explains exactly what is inside, how verification works, where teams get hurt, and how to inspect tokens safely during development.
Bottom line: Decoding reveals claims; only cryptographic verification proves authenticity. Never store sensitive data in a JWT payload. Use client-side decoders for real tokens, never untrusted upload services.
The three-part structure
A JWT is three Base64url-encoded segments joined by dots:
xxxxx.yyyyy.zzzzz
│ │ └── Signature (verify integrity)
│ └── Payload (claims)
└── Header (metadata)
Real example (signature truncated for display):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
| Segment |
Contains |
Encrypted? |
| Header |
Algorithm, type, optional kid |
No |
| Payload |
Claims (user id, roles, expiry) |
No |
| Signature |
HMAC or asymmetric signature |
N/A — it is the proof |
Decoding ≠ verification. A forged token decodes to JSON just fine. Your API must verify the signature on every request.
Header: algorithms and key hints
Decoded header example:
{
"alg": "HS256",
"typ": "JWT"
}
| Field |
Meaning |
alg |
Signing algorithm — HS256, RS256, ES256, etc. |
typ |
Token type — almost always JWT |
kid |
Key ID — tells verifier which public key to use (JWKS rotation) |
Algorithm families
| Family |
Example |
Secret material |
Typical deployment |
| HMAC |
HS256, HS384, HS512 |
Shared symmetric secret |
Monolith, BFF, small services |
| RSA |
RS256, RS384, RS512 |
Private sign / public verify |
Multi-service, public JWKS endpoint |
| ECDSA |
ES256, ES384, ES512 |
Elliptic curve key pair |
Mobile, high-performance APIs |
The alg: none vulnerability
Attackers have historically exploited libraries that accepted {"alg":"none"} and skipped verification. Production APIs must reject unsigned tokens and use an allowlist of permitted algorithms. Never let the token header dictate verification behavior blindly.
Payload: registered, public, and private claims
Decoded payload example:
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022,
"exp": 1516242622,
"iss": "https://auth.example.com",
"aud": "api.example.com",
"scope": "read:orders write:orders"
}
Registered claims (RFC 7519)
| Claim |
Name |
Purpose |
iss |
Issuer |
Who minted the token |
sub |
Subject |
User or entity id |
aud |
Audience |
Intended recipient service |
exp |
Expiration |
Unix timestamp — reject after |
nbf |
Not before |
Reject before this time |
iat |
Issued at |
Mint time |
jti |
JWT ID |
Unique token id for replay prevention |
Custom claims
Teams add roles, permissions, tenant_id, plan, or email. Anything in the payload is visible to the client if the token is stored in the browser. Do not put passwords, credit card numbers, or PII you would not show in DevTools.
Signature: how verification actually works
HMAC (HS256)
signature = HMACSHA256(
base64url(header) + "." + base64url(payload),
shared_secret
)
Verifier recomputes the HMAC with the same secret. Match → authentic. Mismatch → reject.
Risk: every service that verifies must hold the secret. Leak in one microservice compromises all.
RSA / ECDSA (RS256, ES256)
signature = Sign(
base64url(header) + "." + base64url(payload),
private_key
)
Verifier checks with public key (often published at /.well-known/jwks.json). Private key stays on the auth server only.
Benefit: edge APIs verify without shared secrets.
JWS vs JWE: signed vs encrypted
| Standard |
What it does |
Payload visible without key? |
| JWS (signed) |
Integrity + authenticity |
Yes — anyone can decode |
| JWE (encrypted) |
Confidentiality |
No — needs decryption key |
Most API Bearer tokens are JWS. If you need hiding (e.g. sensitive claims in a public channel), use JWE or do not put data in the token.
Safe decode workflow for developers
- Copy token from DevTools → Network → request headers, or from your app's storage (carefully).
- Open a client-side decoder — processing must stay in the browser.
- Inspect
exp first — expired tokens cause unexplained 401s.
- Check
iss and aud match your environment (dev vs staging vs prod).
- Review
roles / scope for authorization bugs.
- Do not share decoded tokens in tickets, Loom videos, or public gists.
What decode tells you
- User id (
sub)
- Whether token is expired
- Granted scopes or roles
- Which auth server issued it
What decode cannot tell you
- Whether signature is valid (need secret or public key)
- Whether user was banned after issuance
- Whether refresh token is still valid
Debugging the "random 401" checklist
| Check |
Question |
exp |
Is token expired? Clock skew between servers? |
nbf |
Is token not yet valid? |
iss |
Wrong auth server / environment mix-up? |
aud |
API rejecting wrong audience? |
| Algorithm |
API expects RS256 but token is HS256? |
| Key rotation |
kid points to retired key? |
| Revocation |
User logged out but access token still unexpired? |
Short-lived access tokens (5–15 minutes) plus refresh flows reduce the window for stale tokens.
JWT vs session cookies
| Aspect |
JWT (stateless) |
Session cookie |
| Server storage |
None required at verifier |
Session store required |
| Revocation |
Hard without blocklist |
Easy — delete session |
| Size |
Large (claims in every request) |
Small cookie id |
| Horizontal scale |
Simple verification |
Needs shared session store |
| XSS risk if in localStorage |
High — JS can read token |
HttpOnly cookies resist JS |
Hybrid pattern (common): short-lived JWT access token + refresh token in HttpOnly cookie + server-side revocation table for refresh tokens.
Common vulnerabilities and mistakes
1. Storing JWTs in localStorage
Any XSS attack exfiltrates the token. Prefer HttpOnly, Secure, SameSite cookies for browser sessions when your architecture allows.
2. Trusting the payload without verification
Middleware must verify signature before reading claims. Never branch on unverified JSON.
3. Long-lived access tokens
A 30-day access token is a 30-day breach window. Short access + refresh is industry standard.
4. Sensitive data in claims
Emails and names in JWTs end up in logs, analytics, and crash reports attached to request headers.
5. Algorithm confusion
Forcing HS256 verification with a public key as the HMAC secret has caused real breaches. Use explicit algorithm allowlists in your JWT library.
OAuth 2.0 and OpenID Connect context
In OIDC, the ID Token is a JWT proving authentication event (who logged in, when, at which issuer). The Access Token may or may not be a JWT depending on authorization server.
When debugging login:
- ID Token → identity claims (
sub, email, name)
- Access Token → API authorization (
scope, aud for resource server)
Decode each separately; they serve different purposes.
HS256 vs RS256: decision guide
Choose HS256 when:
- Single backend signs and verifies
- Few services, shared secret rotation is manageable
Choose RS256 when:
- Multiple APIs verify tokens independently
- You publish JWKS for key rotation
- Third parties verify without sharing your signing secret
Use these codefunc tools alongside this guide:
Practical takeaway
JWTs are readable signed envelopes. Decode in the browser for debugging — verify on the server for security. Check expiry and audience before blaming application bugs. Treat custom claims as public. Use short lifetimes, prefer asymmetric signing at scale, and never paste production tokens into server-side decoders you do not trust.
Sources