JWT explained: structure, claims, signing and common mistakes

Published October 7, 2026

A JSON Web Token (JWT, defined in RFC 7519) is a compact string that carries a set of claims, such as who the user is, who issued the token and when it expires, along with a signature that lets the receiver detect tampering. JWTs are used as OAuth 2.0 access tokens, OpenID Connect ID tokens, and session tokens in countless APIs.

They are also easy to get wrong. Most JWT vulnerabilities come from a server that trusts a token it should have rejected, not from broken cryptography. This article walks through a real token byte by byte, then covers algorithm choice, the checks a server must perform, and the mistakes that keep showing up in security reviews.

Anatomy of a real token

Here is a token signed with HS256. It is one string; the line breaks after each dot are only for readability:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyXzg0MTIiLCJhdWQiOiJhcGkuZXhhbXBsZS5jb20iLCJpYXQiOjE3OTEzNzQ0MDAsImV4cCI6MTc5MTM3NTMwMCwic2NvcGUiOiJvcmRlcnM6cmVhZCJ9.
qIlhE7f5p5MGbPMhmkc0RsTjwv70zO-bLTVvkfGH2_o

The three dot-separated parts are the header, the payload and the signature. The first two are Base64URL-encoded JSON. Decoded, they read:

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

// payload
{"iss":"https://auth.example.com","sub":"user_8412","aud":"api.example.com",
 "iat":1791374400,"exp":1791375300,"scope":"orders:read"}

The header says how the token is signed. The payload holds the claims: this token was issued by auth.example.com for user user_8412, is meant for api.example.com, was issued at 12:00:00 UTC on October 7, 2026 and expires 15 minutes later.

The signature is an HMAC-SHA256 over the first two parts, exactly as they appear in the token, joined with a dot. This Node.js code produces the token above byte for byte:

const crypto = require('crypto');
const b64url = (s) => Buffer.from(s).toString('base64url');

const header  = { alg: 'HS256', typ: 'JWT' };
const payload = { iss: 'https://auth.example.com', sub: 'user_8412', aud: 'api.example.com',
                  iat: 1791374400, exp: 1791375300, scope: 'orders:read' };
const secret  = 'replace-with-32+-random-bytes-from-a-CSPRNG';

const signingInput = b64url(JSON.stringify(header)) + '.' + b64url(JSON.stringify(payload));
const signature = crypto.createHmac('sha256', secret).update(signingInput).digest('base64url');
console.log(signingInput + '.' + signature);

Because the signature covers the encoded bytes, changing a single character of the header or payload, for example "sub":"user_8413", invalidates the token. Re-encoding the same claims with different key order or whitespace also produces a different signature, which is why verifiers check the original string and never a re-serialized copy.

Base64URL, and why the payload is not secret

JWTs use the URL-safe Base64 alphabet from RFC 4648 section 5: - and _ replace + and /, and the trailing = padding is dropped. That keeps the token safe to put in URLs, headers and cookies without further escaping.

Encoding is not encryption. Anyone who holds the token can read every claim:

$ node -e "console.log(Buffer.from(process.argv[1].split('.')[1], 'base64url').toString())" "$TOKEN"
{"iss":"https://auth.example.com","sub":"user_8412","aud":"api.example.com",...}

A signed JWT (a JWS, RFC 7515) guarantees integrity, not confidentiality. Encrypted JWTs (JWE, RFC 7516) exist but are much less common. Treat everything in a typical JWT as visible to the client, to browser extensions, and to every log the token passes through. For the encoding itself, see Base64 encoding explained.

Registered claims

RFC 7519 section 4.1 defines seven registered claim names. None are mandatory in the spec, but your verifier should require the ones it relies on.

ClaimMeaningWhat to check
issIssuerExact match against the issuer you trust.
subSubject (usually the user ID)Unique within the issuer; don't assume it's an email.
audAudience: string or arrayMust contain your service's identifier.
expExpiration timeReject if now is after exp (plus small leeway).
nbfNot beforeReject if now is before nbf (minus leeway).
iatIssued atInformational; useful for max-age policies.
jtiUnique token IDUsed for replay detection and deny lists.

Time claims are NumericDate values: seconds since the Unix epoch, not milliseconds. Setting exp: Date.now() + 3600 in JavaScript creates a token that expires tens of thousands of years from now. Use Math.floor(Date.now() / 1000) + 3600. You can check a timestamp quickly with the Epoch Converter.

HS256 vs RS256 vs ES256

The alg header names a JWA algorithm from RFC 7518. Three cover almost all real deployments:

AlgorithmKeysSignature length in the tokenUse when
HS256 (HMAC + SHA-256)One shared secret signs and verifies43 charactersThe issuer and the verifier are the same service, or a small set you fully control.
RS256 (RSA PKCS#1 v1.5 + SHA-256)Private key signs, public key verifies342 characters (2048-bit key)Many independent services verify tokens from one issuer; maximum library compatibility.
ES256 (ECDSA P-256 + SHA-256)Private key signs, public key verifies86 charactersSame as RS256, but with smaller keys and tokens.

The important difference is who can create tokens. With HS256, every service that can verify a token can also mint one, so a leak from your least-protected consumer compromises everything. With RS256 or ES256, only the issuer holds the private key. Verifiers fetch public keys, usually from a JWKS endpoint, and the kid header selects the right key during rotation. EdDSA (Ed25519, RFC 8037) is a good modern choice where your libraries and identity provider support it.

What a server must verify

Verification is a sequence of checks, and skipping any one of them has caused a real vulnerability somewhere. For each request:

  1. Parse strictly. Exactly three parts, sensible total size, and valid Base64URL-encoded JSON.
  2. Pick the algorithm from your configuration, not from the token. Keep an allow-list such as ['RS256'] and reject anything else.
  3. Select the key from a trusted source. Look up kid in the JWKS of the issuer you configured. Never fetch keys from URLs in the token (jku, x5u) unless they match a strict allow-list.
  4. Verify the signature with a constant-time comparison.
  5. Check exp and nbf with a small leeway.
  6. Check iss and aud.
  7. Check the token type if one issuer creates several kinds of token, for example typ: "at+jwt" for access tokens (RFC 9068), so an ID token can't be used as an access token.
  8. Apply your own rules: scopes, roles, and revocation if you have it.

Here is what steps 2 to 6 look like written out by hand for HS256. It is for understanding only; use a maintained library in production:

const crypto = require('crypto');

function verifyHs256(token, secret, { iss, aud, leeway = 60, now = Math.floor(Date.now() / 1000) }) {
  const parts = token.split('.');
  if (parts.length !== 3) throw new Error('malformed token');
  const [h, p, s] = parts;

  const header = JSON.parse(Buffer.from(h, 'base64url'));
  if (header.alg !== 'HS256') throw new Error(`unexpected alg ${header.alg}`);

  const expected = crypto.createHmac('sha256', secret).update(`${h}.${p}`).digest();
  const actual = Buffer.from(s, 'base64url');
  if (actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) {
    throw new Error('bad signature');
  }

  const claims = JSON.parse(Buffer.from(p, 'base64url'));
  if (typeof claims.exp !== 'number' || now > claims.exp + leeway) throw new Error('expired');
  if (typeof claims.nbf === 'number' && now + leeway < claims.nbf) throw new Error('not yet valid');
  if (claims.iss !== iss) throw new Error('wrong issuer');
  const auds = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
  if (!auds.includes(aud)) throw new Error('wrong audience');
  return claims;
}

With the jose library for Node.js, the same policy is a few options. Every one of them matters:

import { jwtVerify } from 'jose';

const key = new TextEncoder().encode(process.env.JWT_SECRET);
const { payload } = await jwtVerify(token, key, {
  algorithms: ['HS256'],                  // allow-list, never taken from the token
  issuer: 'https://auth.example.com',
  audience: 'api.example.com',
  clockTolerance: 60,                     // seconds of leeway for exp/nbf
});

Classic JWT vulnerabilities

The "none" algorithm

RFC 7519 defines unsecured JWTs with "alg": "none" and an empty signature. Early libraries that took the algorithm from the header would accept such a token as valid, so an attacker could write any claims they liked. Modern libraries reject none by default, but the defense is the allow-list from step 2: if none is not on it, the token fails.

Algorithm confusion (RS256 to HS256)

Suppose a server verifies RS256 tokens and calls a generic verify(token, publicKey) that follows the header's alg. An attacker takes the public key, which is public by design, sets "alg": "HS256", and computes an HMAC using the PEM text of the public key as the secret. The library then runs HMAC with that same "secret" and the signature matches. The fix is again to fix the algorithm per key on the server, and to use libraries that refuse to use an RSA or EC key as an HMAC secret. RFC 8725, JWT Best Current Practices, covers this and the other attacks in this section.

Decoding instead of verifying

Many libraries have both a decode function, which only reads the payload, and a verify function. Using decode in an authorization path means any forged token works. Equally dangerous are verifiers that check the signature but not exp, aud or iss. Without the audience check, a valid token issued for one service can be replayed against another service that trusts the same identity provider.

Secrets in the payload

Because the payload is only encoded, never put passwords, API keys, personal data you wouldn't show the user, or internal infrastructure details in it. Keep claims to identifiers and permissions, and look up anything sensitive on the server.

Weak HMAC secrets

An HS256 token is everything an attacker needs to test secret guesses offline, at high speed, without touching your server. Password-cracking tools support JWTs directly, so secrets like secret, changeme or the company name fall in seconds. RFC 7518 requires a key at least as long as the hash output, which means 256 bits for HS256. Generate it randomly, for example with openssl rand -base64 32, and store it like any other credential.

Expiry, refresh tokens and revocation

A JWT is valid until it expires, wherever it ends up. The standard pattern is a short-lived access token (minutes, not days) paired with a longer-lived refresh token. The refresh token is usually opaque, stored server-side, and exchanged at the authorization server for new access tokens. Rotating refresh tokens on every use and treating reuse of an old one as theft is recommended by the OAuth 2.0 Security Best Current Practice (RFC 9700).

Revocation is the main trade-off of self-contained tokens. Options, from cheapest to most thorough:

If you need instant revocation on every request anyway, a plain server-side session may be simpler than a JWT.

Where to store tokens in the browser

There is no option without trade-offs:

For browser apps, a backend-for-frontend that keeps tokens server-side and gives the browser only a session cookie avoids most of these questions. Whatever you choose, XSS is the threat that matters most, so a strict Content Security Policy does more for token safety than any storage choice.

Clock skew

The issuer and verifier compare timestamps from different machines. If the verifier's clock runs ahead, a fresh token can look expired, and if it runs behind, nbf can reject a token that was just issued. Run NTP everywhere and allow a small leeway: 30 to 60 seconds is typical (clockTolerance in jose, leeway in PyJWT). A leeway of several minutes quietly extends every token's lifetime, so keep it small. If tokens are rejected intermittently right after login, compare the server clocks before you look at the code.

Try it: paste a token into the JWT Decoder to see its header, claims and expiry in readable form, or decode a single segment with the Base64 Encoder/Decoder. Decoding happens in your browser, and the token is never uploaded. Decoding doesn't verify the signature, though, so verification always belongs on your server.

Related articles