Guide
JWT Tokens: How to Decode, Verify, and Avoid the Common Mistakes
Three base64url segments, a signature you must actually verify, and a historical bug — alg: none — that still ships in some libraries.
By Buğra SözeriPublished
A JWT is three base64url-encoded strings joined by dots. The first is metadata, the second is the data you actually care about, and the third is the signature that proves the first two haven’t been changed. Most JWT bugs come from treating the third part as optional — or from misreading what the format guarantees in the first place.
The three-segment structure
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNzQ4NjQ4MDAwfQ.qZdfL_HxR_eRT3z3qZX7Rqv0kK7r0sQYMfRBlLcM2hISplit on the dots, you get header, payload, signature. Each of the first two is a JSON object that’s been base64url-encoded— a URL-safe variant of base64 that replaces + with -, / with _, and omits padding. Paste any token into our JWT decoder to see the three parts inflated to plain JSON.
The header
{
"alg": "HS256",
"typ": "JWT"
}Two fields. alg names the signing algorithm; we cover the values below. typidentifies the token as a JWT — some tools require it, most ignore it. Some tokens also include kid (key ID) so the verifier knows which public key in a key set to use.
The payload
{
"sub": "1234567890",
"iat": 1748648000,
"exp": 1748651600,
"iss": "https://auth.example.com",
"aud": "https://api.example.com"
}Whatever JSON object you choose. The fields are called claims. RFC 7519 defines seven “registered” claim names you should reuse when their meaning matches yours.
The signature
The signature is computed over base64url(header) + “.“ + base64url(payload) using the algorithm and key indicated by the header. The signature itself is the third base64url-encoded segment. It is what makes a JWT a JWT rather than a plain base64 envelope.
The registered claims
- iss (issuer). Who minted the token. Usually a URL identifying your auth service. Verifiers must check this matches the expected issuer.
- sub (subject).Who the token is about — typically the user ID. Should be unique within the issuer.
- aud (audience).Who the token is for — the API URL or service name. A verifier that doesn’t match the expected audience must reject the token, otherwise a token meant for one service can be replayed against another.
- exp (expiration). Unix timestamp (seconds since 1970-01-01 UTC) after which the token is invalid. Verifiers must reject expired tokens.
- nbf (not before). Unix timestamp before which the token is invalid. Optional.
- iat (issued at). Unix timestamp when the token was issued. Informational.
- jti (JWT ID). A unique ID for the token itself, useful for explicit revocation or for preventing replay.
Beyond these, you can put any JSON-serialisable field in the payload. The convention is to namespace custom claims with a URL (e.g. “https://example.com/roles“) to avoid collisions with future registered names.
HS256 vs RS256 vs ES256
HS256 (HMAC-SHA-256, symmetric)
The signature is an HMAC over the encoded header and payload using a shared secret. Anyone who can verify the signature can also createa new one — the same key does both jobs.
Use HS256 when the issuer and verifier are the same party (same service, same secret). Don’t use HS256 when third parties need to verify your tokens — you’d have to share the signing secret, which means they could mint tokens that look like yours.
RS256 (RSA-SHA-256, asymmetric)
The signature is an RSA signature using the issuer’s private key. Verifiers use the issuer’s public key — published in a JWKS endpoint, typically — to check the signature. Verifiers can’t mint tokens.
This is the default for federated identity: OpenID Connect, Auth0, Cognito, every “login with X” flow. The issuer keeps the private key; everyone else trusts the public key.
ES256 (ECDSA over P-256, asymmetric)
Same security model as RS256 but with elliptic-curve signatures. The advantages: ~10x smaller signature size (64 bytes vs 256), faster verification, and stronger security per bit. The disadvantage: ECDSA implementations are easier to get wrong than RSA, and historically have produced critical bugs (the “k reuse” catastrophe in the PlayStation 3, the CVE-2022-21449 Java “psychic signatures” bug).
Use ES256 with a vetted library (Node’s built-in crypto, Go’s crypto/ecdsa, Rust’s ring, Python’s cryptography). Do not roll your own ECDSA. Ever.
EdDSA (Ed25519)
Newer than ES256, harder to misuse, fastest of the asymmetric options. Listed in RFC 8037 as a JWT algorithm but not universally supported — check your stack before adopting.
The alg: none attack
The JWT spec defines a special algorithm value nonemeaning “unsigned.” A token with alg: nonehas an empty signature segment — the dots are still there, but the third part is the empty string.
In 2015, researchers showed that several JWT libraries honoured the alg field in the header as instructions for verification. An attacker could take a legitimate HS256 token, change the header to alg: none, drop the signature, and the library would accept the modified token as valid because “the algorithm says no signature is needed.”
The mitigation is to verify against an expected algorithm, not the algorithm the token claims:
// WRONG — trusts the header
jwt.verify(token, key);
// RIGHT — pins the algorithm
jwt.verify(token, key, { algorithms: ["RS256"] });Modern libraries either reject noneby default or require an explicit opt-in. RFC 8725 (JWT Best Current Practices) mandates the algorithm-pinning pattern. Audit any verification code that doesn’t follow it.
Other common mistakes
Trusting the kid header without validation
The kid header tells the verifier which key to use from a key set. If you blindly use kid as a file path or database key, an attacker can supply a malicious value (../../etc/passwd or SQL injection). Always treat kid as an opaque lookup key into a known set, not as a path or query.
Algorithm confusion (RS256 → HS256)
Some libraries accept the public key for RS256 verification but also accept it as the HMAC secret for HS256. An attacker can change the header from RS256 to HS256, sign the token with the (publicly known) RSA public key as the HMAC secret, and the library will verify. Pin the algorithm.
Long-lived access tokens with no revocation
JWTs are stateless — the verifier doesn’t talk to the issuer per request. That’s the whole performance win, and the whole revocation problem. You can’t invalidate a leaked token before its exp without maintaining a server-side denylist, which defeats the statelessness.
The standard pattern: short-lived access token (5-60 minutes) plus long-lived refresh token (days to weeks) stored server-side. Revoke the refresh token when the user logs out, when a device is reported lost, when an admin forces a re-auth. The access token expires by itself shortly after.
Putting secrets in the payload
The payload is base64url-encoded, not encrypted. Anyone with the token can read every claim. Don’t put passwords, API keys, personal data subject to strict regulation, or anything else you wouldn’t write in a server log. If you need confidentiality, use JWE instead.
Skipping audience and issuer validation
A verifier that checks the signature but not the aud and iss claims will accept tokens minted for a different service. In a federated environment this is a critical bug. RFC 8725 specifies that both must be validated against expected values.
Storing JWTs in localStorage
localStorage is readable by any JavaScript on your origin, which means any XSS becomes a complete token theft. Prefer HTTP-only secure cookies for browser-stored tokens, with a CSRF strategy on top. If you must use localStorage, your XSS defences need to be airtight.
When JWT is the wrong tool
JWTs solve a specific problem: a verifier needs to trust a claim without contacting the issuer. That problem genuinely exists in federated identity and microservices. It usually does not exist in a first-party web app where the client and the server are operated by the same team.
For that case, a regular server-side session with an HTTP-only secure cookie is simpler:
- Immediately revocable. Delete the session row, the user is logged out.
- Smaller over the wire. A session ID is a 30-byte cookie; a JWT can be 500-2000 bytes.
- Simpler security model. No algorithm-confusion bugs, no
alg: none, no payload-secrecy confusion. - Easier to scale than people claim. Redis or a database lookup per request adds a millisecond at most. The performance argument for JWT mostly disappears when measured.
JWT is worth its complexity when you have multiple services that need to trust user identity without all talking to a central session store, or when you’re issuing tokens to third parties — which is exactly the role JWTs play in an OAuth 2.0 / OpenID Connect flow, where the authorization server hands a signed access or ID token to a client that the resource server can verify offline. For one app talking to its own backend, the cookie is the boring right answer.
A safe verification template
// Node.js, jsonwebtoken library
import jwt from "jsonwebtoken";
function verifyToken(token: string): Payload {
return jwt.verify(token, publicKey, {
algorithms: ["RS256"], // pin the algorithm
issuer: "https://auth.example.com", // pin the issuer
audience: "https://api.example.com", // pin the audience
clockTolerance: 5, // small leeway for clock skew
}) as Payload;
}Five lines of options, four of which directly mitigate the attacks above. The defaults of every JWT library in 2026 are safer than they were in 2015, but explicit is still better than implicit when the failure mode is silent authentication bypass.
The honest takeaway
Decoding a JWT is trivial — base64url, split, parse the JSON. Our in-browser decoderdoes it without sending the token anywhere, which matters because tokens often contain identifying claims you wouldn’t want to paste into a third-party site.
Verifying a JWT is also straightforward, as long as you pin the algorithm, validate iss, aud, and exp, and never trust the header to tell you how to verify. Pick HS256 if issuer and verifier are the same party; pick RS256 or ES256 with a vetted library otherwise. And before you reach for a JWT at all, check whether a boring session cookie would do.
Frequently asked questions
- Can I trust a JWT just because it decoded?
- No — decoding only base64url-parses the header and payload. Anyone can decode a JWT; the security property comes entirely from verifying the signature against a key you trust. Always call verify(token, key), not decode(token), in production code paths.
- Is a JWT encrypted?
- A standard JWT (technically a JWS) is signed, not encrypted — the payload is readable to anyone who has the token. If you need confidentiality, use a JWE (JSON Web Encryption) instead. Never put passwords, API keys, or other secrets in a JWT payload assuming they're hidden.
- What is the alg: none attack?
- Early JWT libraries honoured the alg field in the header as instructions — including the special value 'none', which means 'no signature'. An attacker could change the algorithm to none, drop the signature segment, and present a token the library accepted as valid. Modern libraries either reject 'none' by default or require explicit opt-in; if your verification code says verify(token) instead of verify(token, expectedAlg, key), audit it.
- Should access tokens be short or long-lived?
- Short — minutes to an hour at most. JWTs can't be revoked without infrastructure (an explicit denylist defeats the stateless premise). A short access-token life combined with a long-lived refresh token stored server-side is the standard pattern: brief windows of exposure if a token leaks, easy revocation by invalidating the refresh token.
- When is JWT actually the wrong choice?
- When the client is a first-party web app and you control both sides. A regular server-side session with an HTTP-only secure cookie is simpler, immediately revocable, smaller over the wire, and immune to most of the JWT-specific bugs. JWT earns its complexity in genuine third-party scenarios — federated identity, microservices that need to trust a token without calling a central auth service for every request — not in 'we need login on our React app'.
- What's the difference between iat and nbf?
- iat (issued at) is when the token was created — informational, not a validity boundary. nbf (not before) is the earliest time the token is valid — verifiers must reject the token before nbf. exp (expiration) is the latest time the token is valid. Most tokens set iat and exp; nbf is mainly used when issuing tokens that should become valid in the future.
Sources & references
Authoritative references cited by this piece. Verified by Buğra Sözeri on the dates shown and re-checked at every deploy.
- RFC 7519 — JSON Web Token (JWT) — Canonical specification for JWT, including the registered claim names(as of )
- RFC 7515 — JSON Web Signature (JWS) — Signature container used by virtually every JWT in production(as of )
- RFC 7518 — JSON Web Algorithms (JWA) — Catalogues the algorithm names (HS256, RS256, ES256, none, etc.)(as of )
- OWASP JWT Cheat Sheet — Operational guidance and known-attack catalogue referenced throughout(as of )
- RFC 8725 — JSON Web Token Best Current Practices — Official IETF guidance on safe JWT usage including the alg-none mitigation(as of )
Related
- JWT decoderPaste a token, see header and payload — locally, nothing sent anywhere
- JWT glossaryShort reference for the format and its parts
- JWS glossaryJSON Web Signature — the signed container most JWTs use
- JWE glossaryJSON Web Encryption — when you actually need confidentiality
- JWT claim glossaryiss, sub, exp, aud and the rest of the registered claims
- Cryptographic hashing explainedCompanion piece for the HS256 vs RS256 vs ES256 discussion
Published May 31, 2026