A JSON Web Token (JWT) carries claims between two parties in a compact, URL-safe string. Claims are statements about a user or session. You have seen one before: a long string with two dots in it, sent in an Authorization: Bearer header or a cookie. JWTs are self-contained. The token holds the data the server needs, and a signature shows if anyone changed it.
Anyone can read a JWT. The payload is base64url-encoded, not encrypted. Most JWT security bugs come from mixing up *reading* a token with *trusting* it. This guide covers the three parts of a JWT, the registered claims, how HS256 and RS256 signatures work, and why signature verification is what protects your API. Paste a real token into the JWT Decoder as you read to see each part.
The anatomy of a JWT: three base64url parts
A JWT is one string made of three parts separated by dots: header.payload.signature. Each part is base64url-encoded on its own. Base64url is a URL-safe variant of base64. It swaps + and / for - and _ and drops the = padding, so the token fits in URLs, headers and cookies without escaping.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFkYSIsImFkbWluIjp0cnVlLCJpYXQiOjE3MjAwMDAwMDB9
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5cThe first two parts are encoded JSON. Decode the first segment to get the header, which says how the token was signed. Decode the second to get the payload, also called the claims set. The third part is the signature, computed over the first two.
Why do JWTs start with ey?
Almost every JWT starts with eyJ. The header is a JSON object, so its first two characters are {". In base64, {" always encodes to eyJ. The payload is also a JSON object, so the second segment starts with eyJ too. If you see a string that starts with eyJ and has two dots, it is very likely a JWT.
The header
The header is a small JSON object that names the signing algorithm (alg) and the token type (typ). It may also carry a key ID (kid). The verifier uses kid to pick the right key when several keys are in rotation.
{
"alg": "HS256",
"typ": "JWT"
}The payload and registered JWT claims
The payload is a JSON object, and its fields are called claims. The JWT spec (RFC 7519) defines a set of registered claims. These are short, standard names with agreed meanings. You can add your own private claims too, such as roles, tenant IDs or feature flags. Libraries and identity providers only understand the registered ones.
| Claim | Name | Meaning |
|---|---|---|
| iss | Issuer | Who issued the token, for example your auth server's URL. |
| sub | Subject | Who the token is about, usually a user ID. |
| aud | Audience | Who the token is for. This is the API that should accept it. |
| exp | Expiration time | Unix timestamp after which the token must be rejected. |
| nbf | Not before | Unix timestamp before which the token is not yet valid. |
| iat | Issued at | Unix timestamp when the token was created. |
| jti | JWT ID | A unique ID for this token. Used for revocation and replay detection. |
The time claims (exp, nbf, iat) are NumericDate values. They count seconds since the Unix epoch (1970-01-01 UTC), not milliseconds. A common bug is passing JavaScript's Date.now() directly. It returns milliseconds, so the timestamp lands thousands of years in the future.
The jti claim
The jti (JWT ID) claim gives each token a unique ID. RFC 7519 says the issuer must assign it so that two tokens are very unlikely to share a value. A random UUID works well. The claim is optional. It matters when you need to track single tokens: a server can store used jti values to reject a replayed token, or keep a denylist of revoked IDs until each token's exp passes.
{
"iss": "https://auth.example.com",
"sub": "user_8f3a21",
"aud": "https://api.example.com",
"iat": 1720000000,
"exp": 1720003600,
"roles": ["editor"],
"jti": "a1b2c3d4"
}The signature: how HS256 and RS256 work
The signature is what makes a JWT trustworthy. It is computed over the string base64url(header) + "." + base64url(payload) with the algorithm named in alg. Change one byte of the header or payload and the signature no longer matches, so verification fails. Two families of algorithms are common.
HS256: symmetric (HMAC with SHA-256)
HS256 uses one shared secret. The same secret signs and verifies the token, so every party that can verify a token can also forge one. That is fine when the issuer and the verifier are the same service, or trust each other fully. HS256 is fast and simple.
signature = HMAC-SHA256(
key = your_shared_secret,
data = base64url(header) + "." + base64url(payload)
)RS256: asymmetric (RSA with SHA-256)
RS256 uses a key pair. The issuer signs with a private key it never shares. Anyone can verify with the matching public key. Use it when many separate services need to verify tokens but must not be able to create them, such as third parties checking tokens from an identity provider. The public key is often published at a JWKS endpoint.
| HS256 | RS256 | |
|---|---|---|
| Key type | One shared secret | Private and public key pair |
| Who can verify | Anyone with the secret | Anyone with the public key |
| Who can sign | Anyone with the secret | Only the private key holder |
| Best for | One trusted service | Many verifiers or third parties |
| Speed | Faster | Slower to sign, fast to verify |
Decoding is not verifying
This is the most important idea in this guide. Decoding a JWT means base64url-decoding the header and payload to read the JSON inside. It needs no key and proves nothing. Verifying means recomputing the signature with a key, checking that it matches, and then checking the claims. A decoded payload that has not been verified is attacker-controlled input. Treat it like a raw query string a user typed.
You can decode a JWT with only the standard library. This code reads the payload without ever seeing a key.
function decodeJwt(token) {
const [headerB64, payloadB64] = token.split(".");
const decode = (part) =>
JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
return { header: decode(headerB64), payload: decode(payloadB64) };
}
const { header, payload } = decodeJwt(token);
console.log(header.alg); // "HS256"
console.log(payload.sub); // "user_8f3a21", but is it real? Unknown.Decoding is fine for uses that do not involve security. Examples: inspecting a token while debugging, reading exp on the client to decide when to refresh, or logging a jti. The JWT Decoder does this kind of inspection, and it can also verify HS256, HS384 and HS512 signatures if you paste the secret. On the server, never let a decoded claim make an authorization decision.
How JWT verification works, step by step
Verification is a sequence of checks. Skip any one of them and you can open a hole. A good library runs all of them for you, which is the best reason not to write JWT verification yourself.
- Split and decode the header to read the claimed
algand anykid. - Select the key, but only for algorithms on your allow-list. If the header names an
algyou did not configure, reject the token. - Recompute the signature over
header.payloadwith that key. Compare it to the token's signature with a constant-time comparison. - Validate the claims. Reject the token if
expis in the past ornbfis in the future. Check thatissandaudmatch what you expect. - Only now can you trust the payload and act on its claims.
const jwt = require("jsonwebtoken");
try {
// Pin the algorithm(s) and audience/issuer explicitly.
const payload = jwt.verify(token, SHARED_SECRET, {
algorithms: ["HS256"], // reject anything else, incl. "none"
audience: "https://api.example.com",
issuer: "https://auth.example.com",
clockTolerance: 5, // seconds, for minor clock skew
});
// Reaching here means the signature AND exp/nbf/aud/iss all passed.
console.log("Authenticated subject:", payload.sub);
} catch (err) {
// TokenExpiredError, JsonWebTokenError, NotBeforeError, etc.
console.error("Rejected:", err.message);
}Security best practices and the alg:none attack
Most JWT vulnerabilities are not breaks in the cryptography. They are verification steps that someone skipped. A few habits prevent the common ones.
- Always verify the signature. Never make an authorization decision from a payload you only decoded.
- Always check `exp`. If you never check expiry, a leaked token works forever.
- Pin the algorithm. Configure the exact
algyou accept. Do not let the token's header choose. - Keep secrets out of the payload. Anyone holding the token can read it.
- Use a strong secret or key. With HS256, an attacker can brute-force a short or guessable secret offline from one captured token.
- Validate `aud` and `iss` so a token issued for one service cannot be replayed against another.
The alg:none attack
The JWT spec defines an "alg": "none" value that means "unsecured, no signature". It exists for cases where something else protects integrity, and it is a well-known trap. In the classic attack, someone takes a valid token and edits the payload, for example changing "admin": false to true. They set the header algorithm to none and delete the signature, leaving a trailing dot.
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyIiwiYWRtaW4iOnRydWV9.A verifier that trusts the header's alg sees none, skips the signature check and accepts the forged claims. The fix is the allow-list from the previous section. If your code only accepts HS256, or whatever you configured, it rejects a token that claims none before reading the payload. Modern libraries reject none by default. Pinning the algorithm makes that explicit, so you do not depend on a default you did not set.