A JWT looks like random text until you decode it. Paste one into the JWT Decoder and suddenly three distinct sections appear: a header, a payload full of readable claims, and a signature. Understanding what each part does — and what it doesn't do — is essential for using JWTs correctly and securely.
The Three-Part Structure
Every JWT consists of three Base64url-encoded sections separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxpY2VAZXhhbXBsZS5jb20iLCJpYXQiOjE3MzYwMDAwMDAsImV4cCI6MTczNjA4NjQwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
That's: header.payload.signature
Each part is Base64url encoded — a URL-safe variant of Base64 that uses - and _ instead of + and /, and omits padding characters. It's not encryption. Anyone can decode the header and payload. The signature is what provides integrity guarantees.
The Header
Decoded, the header looks like this:
{
"alg": "HS256",
"typ": "JWT"
}
The alg field tells the verifying party which algorithm was used to sign the token. typ is almost always "JWT". That's it — the header is just metadata about the token format.
The Payload
The payload contains claims — statements about the subject of the token and additional metadata. Some claim names are standardized by RFC 7519:
{
"sub": "user_123",
"email": "alice@example.com",
"iss": "https://auth.example.com",
"aud": "https://api.example.com",
"iat": 1736000000,
"exp": 1736086400,
"roles": ["admin"]
}
Here's what the standard claims mean:
sub(subject) — who the token is about, usually a user IDiss(issuer) — who created the token, typically your auth server URLaud(audience) — who the token is intended for, typically your API or appiat(issued at) — Unix timestamp of when the token was createdexp(expiration) — Unix timestamp after which the token should be rejectednbf(not before) — Unix timestamp before which the token should be rejected (optional)
You can add any custom claims you need alongside these. The roles array above is a custom claim.
The Signature
flowchart LR H["header (JSON)"] --> EH["base64url(header)"] P["payload (JSON)"] --> EP["base64url(payload)"] EH --> Cat["header_b64.payload_b64"] EP --> Cat Key["secret or private key"] --> Sign[["HMAC-SHA256<br/>or RSA-SHA256"]] Cat --> Sign Sign --> Sig["base64url(signature)"] Sig --> Out["header_b64.payload_b64.signature_b64"]
The signature is generated by taking the encoded header, a dot, the encoded payload, and running them through the signing algorithm with a secret or private key:
HMACSHA256(
base64url(header) + "." + base64url(payload),
secret
)
The signature confirms two things: the token came from a trusted issuer, and the header and payload haven't been tampered with. Change even one character in the payload and the signature check fails.
HS256 vs RS256: Symmetric vs Asymmetric
flowchart TB
subgraph HS["HS256 — shared secret"]
direction LR
I1["Issuer<br/>(holds secret)"] -- signs --> T1[JWT]
T1 -- verifies with same secret --> V1["Verifier(s)<br/>(also holds secret)"]
end
subgraph RS["RS256 — key pair"]
direction LR
I2["Issuer<br/>(holds private key)"] -- signs --> T2[JWT]
T2 -- verifies with public key --> V2["Verifier(s)<br/>(holds public key only)"]
end
HS256 (HMAC-SHA256) uses a single shared secret. Both the issuer and the verifier need it — which means any service that verifies tokens also has the ability to forge them.
RS256 (RSA-SHA256) uses a private/public key pair. The issuer signs with the private key; anyone can verify with the public key. Only the issuer can create valid tokens. That's the right model when multiple services verify tokens, or when you publish a public JWKS endpoint.
For single-service setups, HS256 is fine. For distributed systems or third-party integrations, RS256 is the safer default.
Where to Store JWTs
This is where most tutorials disagree, so here's the practical breakdown:
localStorage — Easy to use from JavaScript, but vulnerable to XSS attacks. If an attacker can inject a script into your page, they can read the token and exfiltrate it. For any application handling sensitive data, localStorage is a poor choice.
sessionStorage — Same XSS vulnerability as localStorage. Token disappears when the tab closes, which is slightly safer but still not great.
HTTP-only cookies — The token is stored in a cookie with the HttpOnly flag, meaning JavaScript can't read it. This defeats XSS token theft. The tradeoff is CSRF vulnerability, which you mitigate with the SameSite=Strict or SameSite=Lax cookie attribute and CSRF tokens for state-changing requests.
For most web applications, HTTP-only cookies with SameSite protection is the recommended approach.
Common Security Pitfalls
Algorithm Confusion Attacks
sequenceDiagram
participant A as Attacker
participant S as Server (configured for RS256)
Note over S: Trusts whatever alg the header claims
A->>A: Take server's public key (it's public)
A->>A: Forge token: header { alg: "HS256" }<br/>+ desired payload<br/>+ HMAC-SHA256(header.payload, public_key)
A->>S: Bearer <forged token>
S->>S: Reads alg=HS256 from header,<br/>uses public_key as HMAC secret
S-->>A: Accepted as legitimate
Note over S: Fix: pin algorithm server-side, ignore header.alg
Two distinct attacks exploit the alg field in the JWT header — both stem from letting the token itself dictate how it should be verified.
The alg: none attack: some early JWT libraries accepted a token with "alg": "none" in the header and would skip signature verification entirely, treating the token as implicitly trusted. Mainstream libraries patched this long ago, but hand-rolled JWT validation code can still be vulnerable. Always reject tokens that claim alg: none unconditionally.
The HS256/RS256 confusion attack: if a server is configured to verify tokens using RS256 (asymmetric, public key), an attacker can craft a token signed with HS256 using the server's public key as the HMAC secret. Libraries that blindly trust the alg field in the header will then verify the HMAC-SHA256 signature against a key they already know — and accept it. The fix is the same: explicitly specify the expected algorithm server-side and reject any token whose header claims a different one.
Always pin the expected algorithm in your verification code. Don't let the token header dictate it.
Storing Sensitive Data in the Payload
The payload is Base64url-encoded, not encrypted. Anyone who gets the token can read the claims without any key. Use the Base64 tool to verify this yourself — decode any JWT payload and it's plain JSON.
Don't put passwords, credit card numbers, SSNs, or other sensitive data in JWT claims. Stick to identifiers (user ID, roles, permissions) that you'd be comfortable showing to the token holder.
Missing or Overly Long Expiration
A token without an exp claim is valid forever if the signature holds — which is almost never what you want. Short-lived access tokens (15 minutes to 1 hour) with refresh token rotation is the standard pattern.
Don't go so short that it creates a frustrating user experience, though. Find a balance based on actual security requirements, not just defaults.
Not Verifying the Signature
This sounds obvious, but bugs happen. Always verify the JWT signature before trusting any claims. Never decode-and-use without verify.
Inspecting Tokens During Development
When debugging auth issues, you'll spend a lot of time staring at JWT strings. The JWT Decoder decodes a token into readable JSON without needing your secret — useful for quickly checking what claims a token contains, whether it's expired, and what algorithm was used.
For generating test hashes or checking token-related signatures, the Hash Generator can produce HMAC-SHA256 values to cross-check expected signatures manually.
If you want to go deeper on the encoding side, read Base64 Encoding Explained — it covers exactly how the Base64url encoding used in JWTs works, including why the variant uses different characters than standard Base64.
The broader OAuth/OIDC ecosystem that typically issues JWTs is covered in How OAuth Works.
Wrapping Up
JWTs are a solid choice for stateless authentication when you understand their limits: the payload is readable by anyone, short expiry matters, and the signing algorithm should be pinned server-side. For debugging token issues in development, the JWT Decoder is the fastest way to inspect what's inside without writing a line of code.
FAQ
Are JWTs encrypted?
No. By default JWTs are signed, not encrypted. The header and payload are Base64url-encoded, which is trivially reversible — anyone with the token can read every claim. If you need confidentiality, look at JWE (JSON Web Encryption) or simply don't put sensitive data in the payload.
Should I use JWT or session cookies in 2026?
For most monolithic web apps with a single backend, server-side sessions stored in Redis or your database are simpler and easier to revoke. JWTs shine when you have multiple services, mobile apps, or third-party integrations that all need to verify identity without calling a central session store. Pick sessions by default; reach for JWTs when statelessness genuinely buys you something.
How do I revoke a JWT before it expires?
You can't, not directly — that's the tradeoff for statelessness. The common workarounds are short-lived access tokens (5–15 minutes) paired with refresh tokens you can revoke server-side, or maintaining a server-side blocklist of jti claims for emergency revocation. If revocation is a hard requirement, a stateful session is usually the better fit.
Is HS256 secure enough, or do I need RS256?
HS256 with a strong random 256-bit secret is cryptographically secure. The choice is about key distribution, not strength: HS256 means anyone who can verify can also forge, so it only suits a single trust boundary. Use RS256 (or EdDSA) the moment a second party — a different microservice, a mobile client doing local validation, a federated partner — needs to verify tokens.
How long should a JWT live?
Access tokens: 5–60 minutes. Refresh tokens: hours to weeks, depending on sensitivity. Anything claiming "exp": never or multi-day access tokens is a smell — you've taken on session-like risk without the ability to revoke. Banking apps lean toward 5 minutes; consumer SaaS often uses 15–60 minutes.
Can I trust the iss and aud claims to identify the token?
Only after you verify the signature. Until the signature checks out, every byte in the payload is attacker-controlled. The right order is: parse header, pin the expected algorithm, verify signature with the key bound to the expected issuer, then check exp, iss, aud, and nbf. Skipping any of those steps is how the famous JWT vulnerabilities happen.
What's the difference between a JWT and an opaque bearer token?
A JWT carries its own claims — the API can extract user ID and roles directly without a database lookup. An opaque token (a random string) is just a key into a server-side store; the API has to call the auth server to learn anything about it. JWTs trade revocability for performance and decentralization.
Should I put roles and permissions in the JWT payload?
Coarse-grained roles like admin or editor are fine and standard practice. Fine-grained permission lists ("can_edit_invoice_4711") get unwieldy fast and make the token bloat — once it crosses ~4KB you'll have header-size problems with cookies and load balancers. Keep claims small and resolve detailed permissions server-side from the user ID.