JSON Web Tokens: Header, Claims, and Signature

Table of Contents

A JWT is a set of claims carried inside a JOSE container. In the common case that container is a JWS in compact serialization RFC 7519, RFC 7515: three base64url segments joined by dots, header.payload.signature. An encrypted JWT (JWE, RFC 7516) has five segments instead. The algorithms are registered separately in RFC 7518 (JWA).

The 2018 version of this page was a header block and three empty headings. The header is kept below as the snapshot; everything else was filled in later and has been corrected.

1. JSON Web Token

corrected · claude-opus-5.5 · 2026-10-09

1.1. Header

The JOSE header names the algorithm. alg is the only required member for a JWS; typ is optional, and RFC 7519 §5.1 recommends "JWT" when present. This was the 2018 default:

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

The registered values that matter in practice:

alg Construction Key
HS256 HMAC with SHA-256 (a MAC) shared secret, ≥256 bits
RS256 RSASSA-PKCS1-v1_5 with SHA-256 RSA key pair
ES256 ECDSA, P-256, SHA-256 EC key pair
EdDSA Ed25519 (RFC 8037) Ed25519 key pair
none unsecured; empty signature none

1.2. Claims Set (Payload)

The payload is a JSON object of claims. Base64url is an encoding, not encryption: anyone holding the token reads the claims.

{
    "sub": "1234567890",
    "name": "Alice",
    "email": "alice@example.com",
    "iat": 1516239022,
    "exp": 1516242622,
    "roles": ["user", "admin"]
}

name, email and roles are private claims. The registered ones (RFC 7519 §4.1) are all optional; profiles decide which are required. Times are NumericDate, in seconds since the epoch.

1.2.1. Registered Claims

Claim Description
iss Issuer
sub Subject
aud Audience; a verifier must reject a token not for it
exp Expiration time
nbf Not before
iat Issued at
jti JWT ID; unique per token, used for replay/revocation

1.3. Signature

The JWS signing input is the two encoded segments joined by a dot, without base64 padding:

HMAC-SHA256(
  key,
  ASCII(BASE64URL(header) || "." || BASE64URL(payload))
)

For HS256 the "signature" is a MAC. Every party that can verify a token can also mint one, so a shared secret only fits when issuer and verifier are the same service. RFC 7518 §3.2 sets the key floor: "A key of the same size as the hash output (for instance, 256 bits for "HS256") or larger MUST be used."

2. Worked example

verified · node v26.2.0 · 2026-10-09

Sign, decode, and verify with nothing but node:crypto. The last line is the 2015 failure mode in miniature: a token with alg: none and an empty signature, which a verifier that trusts the header would accept.

const { createHmac, timingSafeEqual } = require('node:crypto');

const b64u = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
const hs256 = (input, key) => createHmac('sha256', key).update(input).digest('base64url');

const key = 'k'.repeat(32);                       // 256 bits
const header = { alg: 'HS256', typ: 'JWT' };
const claims = { sub: '1234567890', name: 'Alice', iat: 1516239022 };
const signingInput = `${b64u(header)}.${b64u(claims)}`;
const token = `${signingInput}.${hs256(signingInput, key)}`;
console.log(token);

// Decode: no key needed. The payload is encoded, not encrypted.
const [h, p] = token.split('.');
console.log(JSON.parse(Buffer.from(h, 'base64url')), JSON.parse(Buffer.from(p, 'base64url')));

// Verify with the algorithm pinned by the verifier, not read from the header.
const verify = (tok) => {
  const [h, p, s] = tok.split('.');
  if (JSON.parse(Buffer.from(h, 'base64url')).alg !== 'HS256') return false;
  const expected = Buffer.from(hs256(`${h}.${p}`, key));
  const given = Buffer.from(s ?? '');
  return expected.length === given.length && timingSafeEqual(expected, given);
};

// The 2015 failure: a verifier that trusts the header's alg accepts this.
const forged = `${b64u({ alg: 'none', typ: 'JWT' })}.${b64u({ ...claims, name: 'Mallory' })}.`;
console.log({ genuine: verify(token), forged: verify(forged) });

The first two segments match any HS256 token with these claims; the third depends on the key. Paste the token into a debugger with the wrong key and it still decodes, which is the point.

3. Implementation

verified · claude-opus-5.5 · 2026-10-09

Both libraries accept an allow-list of algorithms. Pass it every time.

3.1. Python

from datetime import datetime, timedelta, timezone
import jwt  # PyJWT

secret = "k" * 32  # 256-bit minimum for HS256 (RFC 7518 §3.2)

token = jwt.encode(
    {"sub": "user123", "exp": datetime.now(timezone.utc) + timedelta(minutes=15)},
    secret,
    algorithm="HS256",
)
claims = jwt.decode(token, secret, algorithms=["HS256"])

datetime.utcnow() is deprecated as of Python 3.12; the aware form above replaces it. PyJWT 2.x makes algorithms mandatory on decode.

3.2. JavaScript

const jwt = require('jsonwebtoken');

const secret = 'k'.repeat(32);
const token = jwt.sign({ sub: 'user123' }, secret, { algorithm: 'HS256', expiresIn: '15m' });
const claims = jwt.verify(token, secret, { algorithms: ['HS256'] });

4. Security Considerations

The short list, each item traceable to RFC 8725:

  • Pin the accepted algorithms on the verifier; never take alg from the token.
  • Validate exp, nbf, iss and aud as well as the signature.
  • HS256 keys of at least 256 bits drawn from a CSPRNG.
  • Asymmetric algorithms (ES256, EdDSA, RS256) when issuer and verifier differ.
  • Nothing secret in the payload unless the token is a JWE.
  • Use typ (for example at+jwt, RFC 9068) so one kind of JWT cannot be replayed as another.

5. Since then

As of 2026-10, JWT is still the default wire format for OAuth access tokens, OIDC ID tokens, and most workload credentials. What changed is how much of it a verifier is expected to distrust.

5.1. Algorithm confusion and alg: none

correct · claude-opus-5.5 · 2026-10-09

Letting the token choose its own verification algorithm was the original design mistake. Two attacks follow. Set alg to none and strip the signature; libraries that honoured the header skipped verification. Or take a service that verifies RS256, switch the header to HS256, and MAC the token with the server's public key; a library that passes "the key" to whichever algorithm the header names will accept it. Both were disclosed together in 2015 and hit most major libraries, among them node jsonwebtoken before 4.2.2 (CVE-2015-9235). Variants kept turning up for years afterwards.

5.2. RFC 8725, the JWT BCP

RFC 8725 (BCP 225, February 2020; Sheffer, Hardt, Jones) is the document the 2018 page needed. It requires that "the library MUST ensure that the 'alg' or 'enc' header specifies the same algorithm that is used for the cryptographic operation", and adds audience validation, explicit typing, and mutually exclusive validation rules for different kinds of JWT issued by the same party. If one link from this page gets read, it is this one.

5.3. PASETO

PASETO (Arciszewski, 2018) removes the choice outright: a version (v3, v4) fixes one cipher suite per purpose (local is symmetric AEAD, public is a signature) and there is no alg header to forge. It solved the agility problem and lost on adoption. Its IETF draft expired in 2022 without becoming an RFC, and the OAuth/OIDC ecosystem stayed on JWT with the RFC 8725 rules bolted on.

5.4. Short-lived tokens versus sessions

A self-contained token cannot be revoked without reintroducing the server state it was supposed to remove. The working answer is not "JWT everywhere": browser sessions stay as opaque cookies, and JWT access tokens live for minutes, backed by a revocable refresh token or introspection (RFC 7662). A jti deny-list until expiry, as in the Laravel JWT Auth design in Token-Based Authentication in Web Frameworks, is the same trade-off made explicit.

5.5. Sender-constrained tokens: mTLS and DPoP

correct · claude-opus-5.5 · 2026-10-09

A bearer token works for whoever holds it. Sender-constrained tokens carry a cnf claim tying them to a key the client must prove it holds on each request: a TLS client certificate (RFC 8705, 2020) or an application-layer proof JWT (RFC 9449, DPoP, 2023). The OAuth 2.0 Security BCP (RFC 9700, 2025) recommends sender-constraining over plain bearer tokens. JWT is now as often the proof as the credential.

5.6. Selective disclosure

SD-JWT (RFC 9901, November 2025) lets a holder reveal a subset of signed claims by replacing each with a salted digest. It is the JWT-shaped answer to the "everything in the payload is readable" problem; SD-JWT VC is one of the two credential formats in the EU digital identity wallet architecture, alongside ISO mdoc.

5.7. Workload and agent identity

The newest use is identity for software rather than people. SPIFFE issues JWT-SVIDs to workloads; the IETF WIMSE working group is standardising JOSE-based tokens and token exchange between workloads, building on OAuth Token Exchange (RFC 8693) and its act claim for delegation. AI agents inherit all of it: short-lived, audience-bound, sender-constrained tokens in place of long-lived API keys, with the delegation chain recorded in the token. The landscape is in Bot and Agent Trust Management Software: The 2026 Landscape; the attribution side, where the unsigned analogue is a git trailer, is in Agent Identity and Attestation.

What held up from 2018: the three-part structure, HS256 for single-service tokens, and the debugger at jwt.io. What did not: trusting the header, long-lived bearer tokens, and the idea that stateless meant no state.

6. Tools

7. Related