JWT Structure: Header vs Payload vs Signature, Every Standard Field, and "Not in the Form" Errors

A JWT looks like one opaque string, but it is three Base64url-encoded parts separated by dots: a header that describes how the token is secured, a payload that carries the claims, and a signature over the first two. Knowing which fields belong where, and which of them an attacker controls, is the basis for decoding tokens safely and for understanding the errors libraries throw when a token is malformed.

The three parts

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9                                  <- header
.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ   <- payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c                          <- signature

header : {"alg":"HS256","typ":"JWT"}
payload: {"sub":"1234567890","name":"John Doe","iat":1516239022}

Each part is Base64url-encoded without padding, so the token is safe in URLs and headers. The header and payload are JSON objects; the signature is binary. Decoding the first two parts needs no key: anyone holding the token can read them. The signature only prevents undetected changes.

Because the first two parts always start with {", they almost always begin with eyJ once encoded. That is a quick way to recognise a JWT in logs, and a reason for secret scanners to look for it.

The header: how the token is secured

The header (JOSE header) tells the verifier how to check the token. It should be small.

alg   signing algorithm: HS256, RS256, ES256, EdDSA ... (required)
typ   media type, usually "JWT"; "at+jwt" marks an OAuth access token (RFC 9068)
kid   key ID: which key from the issuer's JWKS signed this token
cty   content type; "JWT" when the payload is itself a nested JWT
jku   URL of a JWK Set           } rarely needed, and dangerous if the
x5u   URL of an X.509 chain      } verifier follows them from the token
jwk   an embedded public key     }

Everything in the header is attacker-controlled until the signature is verified. A verifier must not let alg choose the algorithm, or it becomes vulnerable to alg: none and algorithm-confusion attacks, and must not fetch keys from jku or x5u URLs found in the token. kid is only a lookup key into keys you already trust. Never use it directly in a file path or an SQL query.

The payload: the claims

The payload is a JSON object of claims. RFC 7519 registers seven, all optional in the base specification, though profiles such as OpenID Connect and OAuth access tokens make several of them required.

iss   issuer: who created the token     "https://auth.example.com/"
sub   subject: who it is about          "user_8f3a2"
aud   audience: who it is for           "https://api.example.com" (or an array)
exp   expiry, in seconds since epoch     1790000000
nbf   not valid before (seconds)         1789996400
iat   issued at (seconds)                1789996400
jti   unique token ID, for revocation    "b1f0c2e4-..."

plus any application claims:             "scope": "orders:read", "roles": ["admin"]

The time claims are NumericDate values: seconds since 1970, not milliseconds. Writing Date.now() into exp creates a token that expires in the year 58000 or so, and some libraries reject it outright. Use Math.floor(Date.now() / 1000).

Keep the payload lean and non-sensitive. It is readable by the client and anyone who intercepts it, it is sent on every request, and cookies are limited to about 4 KB. Put an identifier and the permissions you need in the token, and look up the rest on the server. If the contents must be secret, use an encrypted JWE instead.

The signature

The signature is computed over the exact encoded string header.payload, using the algorithm and key described by the header. Changing a single character of either part invalidates it. It does not cover anything outside the token, such as the HTTP request it arrived with, so a stolen token is valid wherever it is presented until it expires.

signature = HMAC-SHA256(secret, base64url(header) + "." + base64url(payload))   # HS256
signature = RSA-SHA256-sign(privateKey, same input)                             # RS256

An unsecured JWT has "alg": "none" and an empty third part, so it ends with a dot: header.payload.. Real systems should reject these.

Five parts: when it is a JWE

An encrypted JWT (JWE, RFC 7516) has five parts: protected header, encrypted key, initialisation vector, ciphertext and authentication tag. Its header has alg for key management, such as RSA-OAEP or dir, and enc for content encryption, such as A256GCM. The payload cannot be read without the key. A token with four dots is therefore not a malformed JWT, but an encrypted one, and a JWS decoder cannot handle it.

"JWT must consist of header, payload and signature" and similar errors

Libraries throw errors like "jwt malformed", "JWT must consist of three parts" or "the JWT is not in the form of header.payload.signature" when the string does not split into the expected parts. The token is almost never corrupt in transit; the wrong string was passed.

"Bearer eyJhbGci..."       -> the "Bearer " prefix was not stripped
"\"eyJhbGci...\""          -> surrounding quotes from a JSON value or env file
"eyJhbGci...\n"            -> trailing newline or whitespace
"undefined" / "null"       -> the header or cookie was missing
"a1b2c3..." (no dots)      -> an opaque access token, not a JWT
five parts                 -> a JWE: decrypt it, do not decode it as JWS
const auth = req.headers.authorization ?? '';
const [scheme, token] = auth.split(' ');
if (scheme !== 'Bearer' || !token || token.split('.').length !== 3) {
  return res.status(401).end();
}

Not every access token is a JWT. Many OAuth providers issue opaque tokens that can only be checked through the provider's introspection endpoint (RFC 7662). Code that assumes it can decode every token breaks as soon as the provider changes format.

Reading a token without trusting it

Decoding the header and payload is useful when debugging: checking which kid signed a token, whether aud matches your API, or whether exp has already passed. Do it with a tool that runs locally, because a production token is a live credential, and treat what you read as unverified.

# Payload of a JWT on the command line (jq formats the JSON)
printf '%s' "$JWT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .

# Convert exp to a readable date (GNU date; use date -r on macOS)
date -u -d @1790000000

The base64 -d step may warn about missing padding, because JWT parts are unpadded; the output is still correct. In application code, never branch on decoded claims before the signature has been verified.

Sources and further reading

RFC 7519 defines JWT and the registered claims. RFC 7515 defines JWS, the header parameters and the compact serialisation. RFC 7516 defines the five-part JWE format. RFC 9068 defines the at+jwt access-token profile, RFC 8725 lists security best practices, and RFC 7662 defines token introspection for opaque tokens.

Frequently asked questions

What is the structure of a JWT?

Three Base64url-encoded parts separated by dots: `header.payload.signature`. The header says how the token is signed, the payload contains the claims, and the signature covers the first two parts.

What is the difference between the JWT header and payload?

The header holds metadata about the token itself, such as `alg`, `typ` and `kid`. The payload holds the claims about the user or session, such as `sub`, `exp`, `aud` and custom fields like roles. Both are readable by anyone and protected only against modification.

Is the JWT payload encrypted?

No. In a normal signed JWT the payload is only Base64url-encoded, so anyone with the token can read it. Use JWE, a five-part token, if the contents must be confidential.

Why do I get "jwt must consist of header, payload and signature"?

The value passed to the library is not a three-part JWT. Common causes are an unstripped `Bearer ` prefix, surrounding quotes, whitespace, a missing header that became `undefined`, or an opaque or encrypted token.

Are exp and iat in seconds or milliseconds?

Seconds since the Unix epoch. Using milliseconds, such as JavaScript's `Date.now()`, produces expiry dates tens of thousands of years in the future.

How large can a JWT be?

There is no limit in the specification, but tokens travel in headers and cookies, where servers commonly cap headers at 8 KB and browsers cap cookies at about 4 KB. Keep payloads small and put bulky data on the server.