A JSON Web Token (JWT, which RFC 7519 says is pronounced like the English word “jot”) is a short string that carries a set of statements, called claims, from one party to another. The claims are JSON. In almost every token you will meet, they are signed, so the receiver can check who issued them and that nobody changed them. They are not encrypted: anyone who holds the token can read every claim.

This guide takes one token apart, shows how its signature is made, and then covers the two attacks that the JWT best-practice document, RFC 8725, opens with: alg: none and algorithm confusion. Every token below can be pasted into the JWT Decoder, and the code blocks are run by the site’s tests.

A JWT, Taken Apart

Here is a token an authorization server could issue for a 15-minute session. It is signed with HS256 and the demo secret demo-secret-from-zerotool-guide-do-not-reuse:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyLTQ4MjEiLCJhdWQiOiJvcmRlcnMtYXBpIiwiaWF0IjoxNzkwODQ1MjAwLCJleHAiOjE3OTA4NDYxMDAsInNjb3BlIjoib3JkZXJzOnJlYWQifQ.pdFMr6gvHmsFK00Ga4nK4HmsIVrGcv-p-RltD-iQk6U

It is 251 characters long and has three parts separated by dots: 36 characters of header, 170 of payload and 43 of signature. Paste it into the decoder and you get the header

{
  "alg": "HS256",
  "typ": "JWT"
}

and the payload

{
  "iss": "https://auth.example.com",
  "sub": "user-4821",
  "aud": "orders-api",
  "iat": 1790845200,
  "exp": 1790846100,
  "scope": "orders:read"
}

Next to iat the decoder prints Thu, 01 Oct 2026 09:00:00 GMT, and next to exp it prints Thu, 01 Oct 2026 09:15:00 GMT followed by “valid” or “EXPIRED”. That label compares exp with your computer’s clock and nothing else. The third part is shown as the raw string pdFMr6gvHmsFK00Ga4nK4HmsIVrGcv-p-RltD-iQk6U with the note “not verified”: the decoder never sees the secret, so it cannot tell a genuine token from a forged one.

The header says how the token is protected. alg names the algorithm from the JOSE algorithm registry; typ is optional, and RFC 7519 §5.1 recommends the value JWT when it is used. The payload is the claims set. The signature covers the first two parts exactly as they appear in the token.

Base64url, and Why Tokens Start with eyJ

The header and the payload are UTF-8 JSON encoded with base64url (RFC 4648 §5): the alphabet uses - and _ instead of + and /, and JWS strips the trailing = padding (RFC 7515 §2). The result can go into a URL, a cookie or an Authorization: Bearer header without escaping.

The encoding is also why so many tokens begin with eyJ. Base64 turns every 3 bytes into 4 characters of 6 bits each. A JSON object starts with the bytes {" (0x7B 0x22), and when the next byte is a letter, the first three 6-bit groups are 011110, 110010 and 001001, which are e, y and J. Both the header and the payload above start that way, which is a quick way to spot a JWT in a log file.

Base64url is not encryption. Decoding needs no key, so do not put anything in a payload that the bearer, or anyone who finds the token in a log, should not read. The JSON is also not canonical: the decoder re-indents it for display, and the Copy button copies that re-indented JSON. The signature covers the original bytes, so copying a payload out and back in breaks it.

The Claims RFC 7519 Registers

RFC 7519 §4.1 registers seven claim names. All of them are optional; which ones a token must carry is up to the application or a profile built on JWT, such as RFC 9068 for OAuth access tokens.

ClaimNameValueWhat the receiver does with it
issIssuerstring or URICompares it with the issuer it trusts
subSubjectstring or URIUses it as the principal, usually a user ID
audAudiencestring or array of stringsRejects the token if it is not listed
expExpiration timeNumericDateRejects the token on or after this time
nbfNot beforeNumericDateRejects the token before this time
iatIssued atNumericDateRecords when the token was made
jtiJWT IDstringDetects replays of the same token

A NumericDate is the number of seconds since 1970-01-01T00:00:00Z, not milliseconds (§2). The exp and nbf sections allow “some small leeway, usually no more than a few minutes” for clock skew between servers. aud trips people up because it may be a single string or an array (§4.1.3), and a verifier has to handle both forms.

Anything else in the payload, such as scope above, is a private or public claim. Public names are registered in the IANA JSON Web Token Claims registry; private names only mean something between the two parties that agreed on them.

How the Signature Is Made and Checked

The signing input is the encoded header, a dot and the encoded payload, as ASCII bytes (RFC 7515 §5.1). For HS256 the signature is HMAC-SHA256 of that input with the shared secret (RFC 7518 §3.2). The specification’s own example token, from RFC 7515 Appendix A.1, can be checked in a few lines:

import { createHmac, timingSafeEqual } from 'node:crypto';

const token = 'eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9' +
  '.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ' +
  '.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
// The 64-byte key from the "k" member of the example JWK
const key = Buffer.from('AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow', 'base64url');

const [header, payload, signature] = token.split('.');
const expected = createHmac('sha256', key).update(`${header}.${payload}`).digest();
console.log(expected.toString('base64url'));
console.log(timingSafeEqual(expected, Buffer.from(signature, 'base64url')));

Paste that token into the decoder and the header reads {"typ":"JWT","alg":"HS256"}, the payload {"iss":"joe","exp":1300819380,"http://example.com/is_root":true}, and exp shows Tue, 22 Mar 2011 18:43:00 GMT — EXPIRED. The encoded header contains a carriage return and a line feed (LA0KICJ), which shows that the signature is over the exact bytes the issuer produced, whitespace included.

With HS256 everyone who can verify a token can also mint one, because both use the same secret. RFC 7518 §3.2 requires a key at least as long as the hash output, 256 bits for HS256, and RFC 8725 §3.5 forbids using a human-memorable password directly; a short secret can be brute-forced offline from any one token. The asymmetric algorithms split the roles: RS256 (RSASSA-PKCS1-v1_5 with SHA-256), PS256 (RSASSA-PSS), ES256 (ECDSA on P-256) and EdDSA sign with a private key, and anyone with the public key can verify. That is why identity providers publish public keys as a JWK Set and pick the right one with the kid header. The signature length gives the algorithm away:

algSignature bytesBase64url characters
HS2563243
RS256, PS256 (2048-bit key)256342
ES2566486
EdDSA (Ed25519)6486

ES256 signatures in a JWT are the raw 32-byte R and S values joined together, not the DER structure that most crypto libraries return by default (RFC 7518 §3.4). Passing a DER signature, typically 70 to 72 bytes, is a common reason a hand-built ES256 token fails to verify.

Signed, Encrypted, or Neither

RFC 7519 defines a JWT as claims carried in one of two containers. A JWS (RFC 7515) is the three-part signed form above. A JWE (RFC 7516) has five parts, the protected header, the encrypted key, the IV, the ciphertext and the authentication tag, and its claims cannot be read without the key. Paste a JWE into the decoder and it stops with “Invalid JWT: expected 3 dot-separated parts, got 5.”

There is a third form. RFC 7518 §3.6 registers "alg": "none" for an unsecured JWS, and RFC 7519 §6.1 shows one:

eyJhbGciOiJub25lIn0.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.

The signature is empty, so the token ends with a dot. The decoder shows the header {"alg":"none"}, the same claims as the signed example, and an empty signature box. Nothing in the string distinguishes it from a real session token except that header, which is exactly the problem.

alg none and Algorithm Confusion

RFC 8725 §2.1 lists two attacks that hit real libraries. In the first, the attacker changes alg to none, and a library that trusts the header “validates” the token without checking any signature. In the second, a token that should be RS256 is resent as HS256, and the library computes HMAC-SHA256 using the RSA public key as the shared secret. The public key is public, so the attacker can compute the same HMAC. This is CVE-2015-9235 in the Node.js jsonwebtoken package, described in Tim McLean’s 2015 write-up; the library’s changelog shows the algorithms option arriving in 4.2.0 as the fix, and 9.0.0 (December 2022) fixing two more variants, CVE-2022-23540 and CVE-2022-23541.

Both attacks come from the same mistake: letting the token choose how it is checked. The script below builds both forgeries against a freshly generated RSA key and runs them through a verifier that reads alg from the header and one that does not:

import { createHmac, createVerify, generateKeyPairSync } from 'node:crypto';

const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
const b64url = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');

// Trusts the token's own "alg" header. Do not do this.
function naiveVerify(token, key) {
  const [h, p, s] = token.split('.');
  const { alg } = JSON.parse(Buffer.from(h, 'base64url'));
  if (alg === 'none') return s === '';
  if (alg === 'HS256') return createHmac('sha256', key).update(`${h}.${p}`).digest('base64url') === s;
  if (alg === 'RS256') return createVerify('RSA-SHA256').update(`${h}.${p}`).verify(key, s, 'base64url');
  return false;
}

// The application decides the algorithm; the header must agree with it.
function strictVerify(token, key) {
  const [h, p, s] = token.split('.');
  const { alg } = JSON.parse(Buffer.from(h, 'base64url'));
  if (alg !== 'RS256') return false;
  return createVerify('RSA-SHA256').update(`${h}.${p}`).verify(key, s, 'base64url');
}

const claims = b64url({ sub: 'admin', exp: 4102444800 });
const unsigned = `${b64url({ alg: 'none' })}.${claims}.`;
const hsHeader = b64url({ alg: 'HS256', typ: 'JWT' });
const forged = `${hsHeader}.${claims}.` +
  createHmac('sha256', publicPem).update(`${hsHeader}.${claims}`).digest('base64url');

console.log('naive, alg none:', naiveVerify(unsigned, publicPem));
console.log('naive, HS256 with public key:', naiveVerify(forged, publicPem));
console.log('strict, alg none:', strictVerify(unsigned, publicPem));
console.log('strict, HS256 with public key:', strictVerify(forged, publicPem));

The fix is RFC 8725 §3.1: the caller passes the set of allowed algorithms, the library uses no other, and “each key MUST be used with exactly one algorithm”. Current libraries make the safe call the default or the only option. PyJWT 2.x refuses to decode without an algorithms list, refuses a PEM key as an HMAC secret, and rejects none unless you ask for it; with PyJWT 2.10.1 installed, this prints the three errors:

import jwt

secret = "demo-secret-from-zerotool-guide-do-not-reuse"
token = jwt.encode({"sub": "user-4821"}, secret, algorithm="HS256")
public_pem = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE\n-----END PUBLIC KEY-----\n"
unsigned = jwt.encode({"sub": "admin"}, None, algorithm="none")

for call in (
    lambda: jwt.decode(token, secret),
    lambda: jwt.decode(token, public_pem, algorithms=["RS256", "HS256"]),
    lambda: jwt.decode(unsigned, secret, algorithms=["HS256"]),
):
    try:
        call()
    except jwt.PyJWTError as e:
        print(f"{type(e).__name__}: {e}")

What a Verifier Should Check

RFC 7519 §7.2 gives the parsing steps and RFC 8725 §3 adds the policy. In practice a resource server should, in this order:

  1. Split the token into exactly three parts and base64url-decode the header. Reject five-part tokens unless the application expects JWE.
  2. Check alg against the one algorithm configured for the expected key. Reject none unless it was asked for explicitly.
  3. Look up the key. If you use kid, treat it as untrusted input to the lookup (RFC 8725 §3.10), and never fetch keys from a jku or x5u URL taken from the token without an allowlist.
  4. Verify the signature over the original encoded bytes.
  5. Check exp and nbf with at most a few minutes of leeway, iss against the issuer you trust and aud against your own identifier (§3.8, §3.9).
  6. If the same issuer signs several kinds of JWT, check typ as well. RFC 8725 §3.11 recommends explicit types, and RFC 9068 access tokens use at+jwt, so an ID token cannot be replayed as an access token.

A decoder skips steps 2 to 6. Use it to read a token; let your library, configured as above, decide whether to accept one.

JWTs Compared with Session IDs

A session ID is a random handle: the server looks it up on every request and can delete it at any time. A JWT carries its claims with it, so any service holding the key can check it without a shared store. The cost is revocation. A signed token stays valid until exp, even after the user logs out or their password changes, unless every verifier also consults a denylist, typically keyed on jti, which brings the shared store back. Common practice is short access tokens, minutes rather than days, plus a refresh token that the server can revoke.

Size is the other cost. The demo token above is 251 characters for six claims; tokens with group lists or profile data run to several kilobytes and travel on every request. Browsers limit a single cookie to about 4096 bytes (RFC 6265 §6.1 sets that as the minimum a browser must support), so large tokens usually go in an Authorization header instead.

Where the browser keeps the token is a separate decision. Any script running on the page can read localStorage, so one XSS bug exposes the token. A cookie with the HttpOnly attribute cannot be read by script, but the browser attaches it automatically, so it needs SameSite or a CSRF token.

Reading a Token While Debugging

When an API answers 401, decode the token before reading any code:

  • exp in the past? Compare it with the server clock rather than your laptop clock. An exp that decodes to a date tens of thousands of years ahead was written in milliseconds.
  • aud and iss exactly as configured? A trailing slash in https://auth.example.com/ is a different issuer.
  • alg and kid as expected? An identity provider that rotated its keys signs with a new kid that a cached JWK Set does not contain yet.
  • Three parts? Five parts means a JWE; a token that decodes to garbage may be an opaque reference token that was never meant to be read.

The JWT Decoder runs in the browser, and its page loads no analytics or ad scripts, because tokens are often live credentials. To produce test tokens with your own claims and secret, use the JWT Generator; to check a webhook signature built on the same HMAC, use the HMAC SHA256 Generator.