Security
SecurityIntermediate

JWT Explained: What a JSON Web Token Is and How It Breaks

A JWT is a signed JSON object in three base64url parts: header, payload and signature. Anyone can decode it; only the key holder can sign it. How a JWT is built, how to verify one correctly, HS256 vs RS256 vs ES256, the attacks that still work (alg none, algorithm confusion, weak secrets, kid and jku injection) and how to decode a token without leaking it.

Cover image: JWT Explained: What a JSON Web Token Is and How It Breaks
Contents

Almost every login you use today ends with a JSON Web Token. The identity provider hands one to your app, the app sends it with every API call, and the API trusts it without asking anyone. That last part is the whole point of a JWT, and also the reason a small mistake in how it is checked turns into “anyone can be admin”.

This guide explains what a JWT is, part by part, how a server should verify one, which signing algorithm to pick, and the attacks that still work against real systems. Every example token in it can be pasted into the free JWT decoder and security analyzer, which runs entirely in your browser.

JWT explained: a JSON Web Token works like a boarding pass. Anyone can read it, whoever holds it gets in, and only the issuer can print the seal; a pass with alg none is void.

What is a JWT? A JSON Web Token example

A JWT (JSON Web Token, pronounced “jot”) is a compact, URL-safe string that carries a set of claims and a signature over them. It is defined in RFC 7519; the signing and encryption formats underneath it are JWS (RFC 7515) and JWE (RFC 7516).

A signed JWT has three parts separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzQyIiwicm9sZSI6ImVkaXRvciIsImlhdCI6MTc2NzIyNTYwMCwiZXhwIjoxNzY3MjI2NTAwfQ.92XVsnsq7eHmUoHn1v4cCfyufbIv5l0Pm8L_Ec6PDH8

Decode the first two parts from base64url and you get two ordinary JSON objects:

header
{ "alg": "HS256", "typ": "JWT" }
payload
{ "sub": "user_42", "role": "editor", "iat": 1767225600, "exp": 1767226500 }

The third part is the signature: 32 bytes of HMAC-SHA256 over header.payload, computed with a secret only the issuer knows. Change one character of the payload, for example "role": "admin", and the signature no longer matches.

That is all a JWT is: claims plus proof of who wrote them. The interesting part is everything around it.

Anatomy of a JSON Web Token

JWT structure: header, payload and signature are base64url-encoded and joined by dots; the signature is computed over the encoded header and payload with the issuer’s key.
Two readable JSON objects and one signature that binds them to a key.

The header

The header says how to verify the token. The fields that matter:

The header
Field Meaning Watch out for
alg Signing algorithm: HS256, RS256, ES256, EdDSA… none, or an algorithm your server never issues
typ Token type: JWT, or at+jwt for OAuth access tokens (RFC 9068) Missing type makes token confusion easier
kid Key ID: which key in the issuer’s key set signed it Treated as a file path or SQL value
jku, x5u URL of a key set or certificate Fetched blindly by the verifier
jwk A public key embedded in the token itself Trusted as the verification key

The payload: claims

The payload is a JSON object of claims. RFC 7519 registers seven of them; everything else is up to the issuer.

The payload: claims
Claim Name What the verifier does with it
iss Issuer Must match the identity provider you trust
sub Subject The user or client the token is about
aud Audience Must contain your API’s identifier
exp Expiration time Reject after this moment
nbf Not before Reject before this moment
iat Issued at Age checks, debugging
jti JWT ID Unique ID for one-time use or revocation

Times are NumericDate: whole seconds since 1970-01-01 UTC. A surprising number of bugs come from writing milliseconds (Date.now() in JavaScript) instead: the token then expires tens of thousands of years from now.

The signature

For HS256 the signature is:

HMAC-SHA256( base64url(header) + "." + base64url(payload), secret )

For RS256, ES256 or EdDSA it is a public-key signature over the same input. Either way it covers the encoded header and payload, so the server verifies exactly the bytes it received.

Signed is not encrypted

Base64url is an encoding, not encryption. Anyone holding a JWT can read every claim in it, no key needed:

Terminal window
node -e 'console.log(JSON.parse(Buffer.from(process.argv[1].split(".")[1], "base64url")))' "$TOKEN"

This is the most common misunderstanding about JWTs, and it has consequences. Tokens end up in browser storage, logs, analytics, crash reports and proxy caches. Whatever you put in the payload, you are publishing to all of them.

A JWT is a boarding pass. Anyone who sees it can read the name on it, whoever holds it gets on board, and only the issuer can print the seal that makes it valid.

So keep the payload to identifiers and permissions. No passwords, API keys, card numbers or personal data beyond what the API needs. If the content really must be confidential, use an encrypted JWT (JWE, five dot-separated parts instead of three), or keep the data on the server and put an opaque ID in the token.

How to verify a JWT correctly

Decoding tells you what a token claims. Verifying tells you whether to believe it. Verification is a checklist, and the order matters: each step closes a specific attack.

JWT verification checklist: limit size, pin the algorithm, choose the key from your own key set, check the signature, then exp and nbf, then iss, aud and typ. Each step blocks a named attack.
Most JWT breaches skip one of these steps; none of them break the cryptography.
  1. Bound the input. Reject tokens above a sane size before parsing.
  2. Pin the algorithm. Accept only the algorithms you actually issue, configured on the server. Never let the token’s alg choose.
  3. Choose the key yourself. Look the kid up in your key set (a JWKS from your identity provider). Ignore jwk, jku and x5u from the token.
  4. Check the signature.
  5. Check time: exp and nbf, with a small clock tolerance (30–60 seconds), and require exp to be present.
  6. Check context: iss is your identity provider, aud contains your API, typ is what this endpoint expects.

With a maintained library this is a few lines. In Node.js with jose:

verify.js
import { createRemoteJWKSet, jwtVerify } from 'jose';
const jwks = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'));
const { payload } = await jwtVerify(token, jwks, {
algorithms: ['ES256'],
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
typ: 'at+jwt',
clockTolerance: 30,
});

And in Python with PyJWT:

verify.py
import jwt
jwks = jwt.PyJWKClient("https://auth.example.com/.well-known/jwks.json")
key = jwks.get_signing_key_from_jwt(token).key
claims = jwt.decode(
token, key,
algorithms=["ES256"],
issuer="https://auth.example.com",
audience="https://api.example.com",
leeway=30,
options={"require": ["exp", "iat", "iss", "aud", "sub"]},
)

The highlighted lines are the security. Delete any one of them and the code still “works” in every test, which is exactly why they get deleted.

HS256 vs RS256 vs ES256: which algorithm?

The real question is not speed or key length. It is who can mint tokens.

HS256 vs RS256 and ES256: with HS256 every service that verifies tokens holds the shared secret and could forge them; with RS256 or ES256 only the issuer holds the private key and services verify with the public JWKS.
With a shared secret, every verifier is also a potential issuer.
HS256 vs RS256 vs ES256: which algorithm?
Algorithm Type Signature size Use it when
HS256 HMAC, shared secret 32 bytes One service issues and verifies its own tokens
RS256 RSA PKCS#1 v1.5 256 bytes (2048-bit key) Broadest library support; most identity providers default to it
PS256 RSA-PSS 256 bytes (2048-bit key) RSA, with the more modern padding
ES256 ECDSA P-256 64 bytes Small tokens, wide support; a good default for new systems
EdDSA Ed25519 64 bytes Simplest safe signatures, where your libraries support it

With HS256 the verification key is the signing key. If five microservices verify tokens, five services hold a secret that lets them create a token for any user, and a leak from the least protected one compromises all of them. With an asymmetric algorithm only the identity provider holds the private key; services fetch the public keys from a JWKS URL, and you can rotate keys by publishing a new kid.

If you do use HS256, the secret must be random and long: RFC 7518 requires a key at least as long as the hash output (256 bits for HS256), and RFC 8725 says human-memorable passwords must not be used as HMAC keys.

Terminal window
openssl rand -base64 32

Six JWT attacks that still work

None of these break HMAC, RSA or ECDSA. They exploit the verifier: code that trusts the token to explain how it should be trusted. Tim McLean’s 2015 write-up of critical vulnerabilities in JWT libraries made the first two famous, and they are still found in custom code and old dependencies.

1. alg: none

JWS defines an “unsecured” algorithm called none with an empty signature. An attacker takes a valid token, changes the payload, sets "alg": "none", removes the signature and keeps the trailing dot. A verifier that honours the header accepts it. Variants such as None or nOnE slip past naive string checks.

Fix: an explicit algorithm allow-list on the server, as in the code above.

2. Algorithm confusion (RS256 to HS256)

The server expects RS256 and verifies with the issuer’s public key. The attacker changes alg to HS256 and signs the token with HMAC, using that public key, which is public, as the secret. A library that picks the algorithm from the header now runs HMAC with the same key bytes, and the forged token verifies.

Fix: pin the algorithm, and use libraries that bind a key to one algorithm type.

3. Weak HMAC secrets

An HS256 token is an offline puzzle: anyone with one token can test candidate secrets without touching your server. secret, changeme, the framework’s sample value, or your-256-bit-secret copied from a tutorial fall in seconds; hashcat even has a dedicated mode for JWTs (16500).

Fix: a random 256-bit secret from a secret manager, or move to an asymmetric algorithm.

4. Key injection through the header

jwk embeds a public key in the token, and jku or x5u point to a URL where the key can be found. A verifier that uses them lets the attacker sign with their own key and supply the matching public key. kid is subtler: it is just a string, and code that uses it as a file path (../../dev/null) or inside a SQL query turns it into path traversal or injection.

Fix: take keys only from your own configured key set; treat kid as an opaque lookup key. RFC 8725 calls this out explicitly: do not trust received claims.

5. Missing audience and issuer checks

Your company has two APIs that trust the same identity provider. A token issued for the low-risk one is replayed against the payments API, and it verifies, because the signature and expiry are fine. Nobody checked aud.

Fix: every API checks that aud names it and that iss is the expected issuer. Use explicit typ values so an ID token cannot be used as an access token.

6. Tokens that live forever

A token without exp, or with a lifetime of months, is a long-lived password that cannot be changed. A stolen one keeps working after the user logs out, changes their password or leaves the company.

Fix: access tokens measured in minutes, exp required by the verifier, and rotating refresh tokens that the server can revoke.

Library bugs exist too: in 2022 Java’s ECDSA implementation (versions 15 to 18) accepted an all-zero signature as valid for any message, which made every ES256 token forgeable on unpatched runtimes (CVE-2022-21449, “Psychic Signatures”). Keeping crypto runtimes and JWT libraries patched is part of the checklist.

JWT decoder: read a token without leaking it

A JWT is a bearer credential: whoever holds an unexpired production token can usually call your API as that user. Pasting one into a random website that uploads it to a server is handing it over.

Three safe options:

  • Command line: the Node.js one-liner above, or your language’s equivalent. Nothing leaves the machine.
  • A local browser decoder: the JWT decoder and security analyzer on this site decodes, verifies and audits tokens with Web Crypto in the page itself; tokens, secrets and keys are never uploaded, stored or put into the URL.
  • Expired or test tokens, when you only need to see the structure.

Here is what the analyzer reports for a token signed with your-256-bit-secret, the sample secret that circulates in countless tutorials:

JWT decoder and security analyzer showing grade F for an HS256 token: signed with a well-known secret and valid for four years, with the decoded header and payload and a fix for each finding.
The analyzer tries more than 100 well-known secrets locally and explains every finding with a fix.

It flags the forgeable signature first, then the multi-year lifetime and the missing iss and aud, and gives a fix for each. Paste the issuer’s public key or JWKS and it verifies RS256, PS256, ES256 and EdDSA signatures as well. If you work with AI agents, the same analysis is exposed to them as a WebMCP tool, analyze_jwt.

JWT vs session cookies: when to use a JWT

JWTs are popular enough that they get used where a session would be simpler. An honest comparison:

JWT vs session cookies: when to use a JWT
Server-side session JWT
State Stored on the server, cookie holds an ID Self-contained, verified with a key
Revocation Instant: delete the session Waits for exp, unless you add server state
Scaling Needs a shared session store Any service with the public key can verify
Across services and domains Awkward What it was designed for
Size per request A few dozen bytes Hundreds of bytes to several KB

Use a session for a classic web app with one backend. Use JWTs when several services, an API gateway or third parties must verify identity without a shared store, and wherever OAuth 2.0 and OpenID Connect already hand you one: ID tokens are always JWTs, and many access tokens are.

JWT vs OAuth

They are not alternatives. OAuth 2.0 is a protocol for getting an access token on a user’s behalf; a JWT is a token format. An OAuth access token can be a JWT (RFC 9068 standardises that profile, with typ: at+jwt) or an opaque random string the API checks with the authorization server. OpenID Connect, the identity layer on top of OAuth, always uses a JWT for the ID token.

The two combine well. For browser apps, the safest place for a JWT is an HttpOnly cookie, not localStorage; the details, including CSRF protection, are in why you should stop storing JWTs in localStorage. If you run your own identity provider, Keycloak or Authentik will issue and sign the tokens for you, and a stronger login in front of them, such as passkeys, protects the moment a token is created.

The HMAC behind HS256 is the same primitive that signs webhooks; webhook signatures face the same rules about secret strength and constant-time comparison.

The Bottom Line

A JWT is a readable JSON document with a signature. The format is simple and the cryptography is sound; what fails in practice is the code around it. Pin the algorithm, choose the key yourself, require exp, check iss and aud, keep tokens short-lived and keep secrets out of the payload. Do that with a maintained library, and a JWT is one of the most convenient credentials you can hand out.

And the next time you need to see what is inside one, decode it locally, not in a page that uploads it.

Frequently asked questions

What is a JWT?

A JSON Web Token (JWT, pronounced "jot") is a compact, URL-safe way to pass claims between two parties, defined in RFC 7519. A signed JWT has three base64url-encoded parts separated by dots: a header that names the algorithm, a payload of claims such as the subject (sub), issuer (iss), audience (aud) and expiry (exp), and a signature computed over the first two parts. The receiver verifies the signature with a key it trusts and then reads the claims without a database lookup.

Is a JWT encrypted?

No. A normal JWT is signed, not encrypted. The header and payload are only base64url-encoded, so anyone who has the token can decode and read them, for example with one line of Node.js or a local decoder. The signature only proves that the issuer created the token and that it was not modified. If the content must stay secret, use an encrypted JWT (JWE, five parts instead of three) or keep the data on the server and put only an opaque identifier in the token.

How do I decode a JWT?

Split the token at the dots and base64url-decode the first two parts; each one is a JSON object. In Node.js: JSON.parse(Buffer.from(token.split('.')[1], 'base64url')). Decoding is not verifying: it tells you what the token claims, not whether the claims are true. To trust a token, verify the signature with the issuer's key and check exp, iss and aud with a maintained library.

Is it safe to paste a JWT into an online decoder?

Only if the decoder runs entirely in your browser. A JWT is a bearer credential: whoever holds an unexpired production token can usually call the API as that user. Prefer a decoder that does all work locally and sends nothing to a server, a command-line one-liner, or an expired or test token. Never paste a signing secret or a private key into any website.

What is the difference between HS256 and RS256?

HS256 is HMAC with SHA-256: the same shared secret signs and verifies, so every service that can verify a token can also mint one. RS256 is an RSA signature: the issuer signs with a private key and everyone else verifies with the public key, which can be published as a JWKS. Use HS256 only when a single service both issues and checks tokens; with several verifiers, prefer an asymmetric algorithm such as ES256, EdDSA or RS256.

Can a JWT be revoked?

Not by itself. A JWT stays valid until its exp time because the verifier does not ask anyone. To end access early you need server-side state: a denylist of token IDs (jti), a per-user session version checked on each request, or very short access tokens combined with refresh tokens that the server can revoke. Short lifetimes are the usual answer, typically minutes for access tokens.

JWT vs session cookie: which should I use?

For a single web application with its own backend, a server-side session behind an HttpOnly cookie is simpler and can be revoked instantly. JWTs pay off when the token must be verified by several services, across domains or at an API gateway without a shared session store, and they are the standard format for OpenID Connect ID tokens and many OAuth access tokens. The two also combine: a JWT can travel inside an HttpOnly cookie.

What is the difference between JWT and OAuth?

OAuth 2.0 is an authorization protocol: it defines how an application obtains an access token on a user's behalf. A JWT is a token format. OAuth does not require JWTs; an access token can be a JWT (standardised in RFC 9068 with typ at+jwt) or an opaque string the API validates with the authorization server. OpenID Connect, built on OAuth 2.0, always issues its ID token as a JWT.

From the community

Discussion on Mastodon & Bluesky

Replies to this article from the open web, pulled in live. No ads, no tracking scripts.

Loading replies …
Reply on MastodonReply on Bluesky

Reply there — it shows up here automatically.

ENDE