A JSON Web Token (JWT) is a compact, URL-safe way to carry claims — statements about a user or session — between two parties. You have almost certainly seen one: a long, cryptic string with two dots in it, sitting in an Authorization: Bearer header or a cookie. JWTs power a huge share of modern authentication and API authorization because they are self-contained: the token itself holds the data a server needs, signed so that tampering is detectable.
The catch is that a JWT is trivially readable by anyone. The payload is not encrypted — it is merely base64url-encoded. This single fact is the source of most JWT security bugs: developers confuse *reading* a token with *trusting* it. This guide walks through the three parts of a JWT, the standard claims, how HS256 and RS256 signatures actually work, and why signature verification is the only thing standing between your API and an attacker. Paste a real token into the JWT Decoder as you read to see each concept in practice.
The Anatomy of a JWT: Three Base64url Parts
A JWT is a single string made of three parts separated by dots: header.payload.signature. Each part is independently base64url-encoded — a URL-safe variant of base64 that swaps + and / for - and _ and drops the = padding, so the token survives inside URLs, headers, and cookies without escaping.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFkYSIsImFkbWluIjp0cnVlLCJpYXQiOjE3MjAwMDAwMDB9
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5cThe first two parts are just JSON that has been encoded. Decode the first segment and you get the header, which describes how the token was signed. Decode the second and you get the payload (also called the claims set). The third part is the signature, computed over the first two.
The header
The header is a small JSON object naming the signing algorithm (alg) and the token type (typ). It may also carry a key ID (kid) so the verifier knows which key to use when several are in rotation.
{
"alg": "HS256",
"typ": "JWT"
}The Payload and Registered Claims
The payload is a JSON object whose fields are called claims. The JWT spec (RFC 7519) defines a set of registered claims — short, standardized names with agreed meanings. You are free to add your own private claims too (roles, tenant IDs, feature flags), but the registered ones are what libraries and identity providers understand.
| Claim | Name | Meaning |
|---|---|---|
| iss | Issuer | Who issued the token (e.g. your auth server's URL). |
| sub | Subject | Who the token is about — typically a user ID. |
| aud | Audience | Who the token is intended for — 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 identifier, useful for revocation or replay detection. |
The time-based claims (exp, nbf, iat) are NumericDate values — seconds since the Unix epoch (1970-01-01 UTC), not milliseconds. A common bug is passing JavaScript's Date.now() (milliseconds) directly, which produces timestamps thousands of years in the future.
{
"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 encoded header and payload — literally the string base64url(header) + "." + base64url(payload) — using the algorithm named in alg. Change a single byte of the header or payload and the signature no longer matches, so verification fails. Two families of algorithms dominate.
HS256 — symmetric (HMAC + SHA-256)
HS256 uses a single shared secret. The same secret both signs and verifies the token, so every party that can verify a token can also forge one. That is fine when the issuer and verifier are the same service (or trust each other completely), and it is fast and simple.
signature = HMAC-SHA256(
key = your_shared_secret,
data = base64url(header) + "." + base64url(payload)
)RS256 — asymmetric (RSA + SHA-256)
RS256 uses a key pair. The issuer signs with a private key it never shares; anyone can verify with the corresponding public key. This is the right choice when many independent services need to verify tokens but must not be able to mint them — for example, third parties validating tokens from an identity provider. The public key is often published at a well-known JWKS endpoint.
| HS256 | RS256 | |
|---|---|---|
| Key type | One shared secret | Private + 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 | Single trusted service | Distributed verifiers / third parties |
| Speed | Faster | Slower to sign, fast to verify |
Decoding Is Not Verifying
This is the most important idea in the whole guide. Decoding a JWT means base64url-decoding the header and payload to read the JSON inside — no key required, no trust established. Verifying means recomputing the signature with a key, confirming it matches, and checking the claims. A decoded-but-unverified payload is attacker-controlled input. Treat it exactly as you would treat a raw query string a user typed.
You can decode a JWT with nothing but the standard library, which makes the point concrete: 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.Because decoding is unauthenticated, it is safe for non-security uses: inspecting a token while debugging, reading a non-sensitive exp to decide when to refresh on the client, or logging a jti. Use the JWT Decoder for exactly this kind of inspection — but never let a decoded claim make an authorization decision on the server.
How Verification Actually Works, Step by Step
Proper verification is a sequence of checks, and skipping any one of them can open a hole. A compliant library does all of this for you, which is the strongest reason not to hand-roll JWT verification.
- Split and decode the header to learn the claimed
algand anykid. - Select the key — but only from an allow-list of algorithms you expect. If the header names an
algyou did not configure, reject immediately. - Recompute the signature over
header.payloadwith the selected key and compare it, using a constant-time comparison, to the token's signature. - Validate the claims: reject if
expis in the past ornbfis in the future, and confirmissandaudmatch what you expect. - Only now may 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 were skipped. A few disciplined habits eliminate the common ones.
- Always verify the signature. Never make an authorization decision from a decoded-but-unverified payload.
- Always check `exp`. A token without expiration validation is a permanent credential if it ever leaks.
- Pin the algorithm. Configure the exact
algyou accept; do not let the token's header choose. - Keep secrets out of the payload. It is readable by anyone holding the token.
- Use a strong secret or key. For HS256, a short or guessable secret can be brute-forced offline against a captured token.
- Validate `aud` and `iss` so a token minted for one service cannot be replayed against another.
The alg:none attack
The JWT spec defines an "alg": "none" value meaning "unsecured — no signature." It exists for cases where integrity is guaranteed by other means, but it is a notorious footgun. In the classic attack, an adversary takes a valid token, edits the payload (say, flipping "admin": false to true), sets the header algorithm to none, and deletes the signature entirely — leaving a trailing dot.
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyIiwiYWRtaW4iOnRydWV9.A verifier that naively trusts the header's alg sees none, performs no signature check, and accepts the forged claims. The defense is the same allow-list from the previous section: because your code only accepts HS256 (or whatever you configured), a token claiming none is rejected before any payload is read. Modern libraries reject none by default, but pinning the algorithm makes the guarantee explicit instead of relying on a default you did not set.