JWT Explained: Header, Payload, Signature and Decoding
· by webmaster
A JSON Web Token looks like random text, but it is three pieces of Base64URL-encoded data joined by dots, and you can read two of them with a string split. This guide walks through each part and shows how to decode a token in JavaScript, PHP and Python without a library. It then covers the part that causes security bugs: decoding a token tells you nothing about whether to trust it. The mistakes that make JWTs dangerous are at the end. To follow along, paste any token into the JWT decoder and compare what you see with the code below.
A JWT has three parts: header, payload and signature
Here is a token signed with the secret my-secret:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFkYSIsImlhdCI6MTc1NzQ2MjQwMCwiZXhwIjoxNzU3NDY2MDAwfQ
.Y5G1d94a5s3wJg4kKQYsrlML5Hu_ZCukajY7lXIbnfA
The line breaks are only for readability. The real token is one string, with . between the parts.
| Part | Decoded content | Purpose |
|---|---|---|
| Header | {"alg":"HS256","typ":"JWT"} | Which algorithm signed the token |
| Payload | {"sub":"1234567890","name":"Ada","iat":1757462400,"exp":1757466000} | The claims: who, when, what |
| Signature | 32 raw bytes | Proof the first two parts were not changed |
The header and payload are JSON objects encoded with Base64URL. The issuer computes the signature over the string header.payload with the algorithm named in the header. If one character of the payload changes, the signature no longer matches. That check is the whole security model, and everything below either depends on it or breaks it.
JWT uses Base64URL, not standard Base64
Standard Base64 uses +, / and = padding. All three are awkward in URLs and HTTP headers, so JWT uses the URL-safe alphabet from RFC 4648: - for +, _ for /, and no padding. When a JWT segment pasted into a plain Base64 tool gives an error or a truncated result, the missing padding is the reason. Our Base64 decoder detects the URL-safe variant automatically, and the code below does the conversion by hand. The Base64 vs URL encoding article covers the variants in more depth.
Decoding a JWT without a library takes three steps
Split on dots, turn Base64URL back into bytes, and parse the JSON. You need no secret for this, which is the subject of the next section.
JavaScript (Node.js): Buffer reads base64url directly
Node’s Buffer supports base64url natively, so this version is short.
function decodeJwt(token) {
const [header, payload] = token.split(".");
const decode = (part) => JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
return { header: decode(header), payload: decode(payload) };
}
console.log(decodeJwt(token));
// { header: { alg: 'HS256', typ: 'JWT' },
// payload: { sub: '1234567890', name: 'Ada', iat: 1757462400, exp: 1757466000 } }
JavaScript (browser): atob needs padding restored
Browsers only have atob(), which requires standard Base64 with padding. Swap the characters, add the padding back, then decode the bytes as UTF-8 so non-ASCII names come through intact.
function base64UrlDecode(str) {
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
const bytes = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
function decodeJwt(token) {
const [header, payload] = token.split(".");
return {
header: JSON.parse(base64UrlDecode(header)),
payload: JSON.parse(base64UrlDecode(payload)),
};
}
PHP: strtr and str_pad restore standard Base64
PHP has no URL-safe flag on base64_decode, so strtr swaps the alphabet and str_pad restores the padding.
<?php
function base64UrlDecode(string $data): string {
$padded = str_pad($data, strlen($data) + (4 - strlen($data) % 4) % 4, '=');
return base64_decode(strtr($padded, '-_', '+/'));
}
function decodeJwt(string $token): array {
[$header, $payload] = explode('.', $token);
return [
'header' => json_decode(base64UrlDecode($header), true),
'payload' => json_decode(base64UrlDecode($payload), true),
];
}
print_r(decodeJwt($token));
Python: urlsafe_b64decode still needs padding
Python’s urlsafe_b64decode handles the alphabet but still requires padding, which the -len(data) % 4 expression adds.
import base64
import json
def base64url_decode(data: str) -> bytes:
padded = data + "=" * (-len(data) % 4)
return base64.urlsafe_b64decode(padded)
def decode_jwt(token: str) -> dict:
header, payload, _signature = token.split(".")
return {
"header": json.loads(base64url_decode(header)),
"payload": json.loads(base64url_decode(payload)),
}
print(decode_jwt(token))
All three print the same header and payload, and none of them checks the signature.
Decoding a JWT is not verifying it
Anyone who holds a JWT can decode it, so never put private data in the payload. A token is encoded, not encrypted. A password, a card number or anything else private does not belong there. Verification is a separate step: recompute the signature with the key and compare it with the third part.
For HS256, the signature is an HMAC-SHA256 over header.payload. In Node:
const crypto = require("node:crypto");
function verifyHS256(token, secret) {
const [header, payload, signature] = token.split(".");
const expected = crypto
.createHmac("sha256", secret)
.update(header + "." + payload)
.digest("base64url");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
console.log(verifyHS256(token, "my-secret")); // true
console.log(verifyHS256(token, "wrong")); // false
The same check in PHP and Python:
function verifyHs256(string $token, string $secret): bool {
[$header, $payload, $signature] = explode('.', $token);
$raw = hash_hmac('sha256', "$header.$payload", $secret, true);
$expected = rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
return hash_equals($expected, $signature);
}
import base64, hmac, hashlib
def verify_hs256(token: str, secret: str) -> bool:
header, payload, signature = token.split(".")
digest = hmac.new(secret.encode(), f"{header}.{payload}".encode(), hashlib.sha256).digest()
expected = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
return hmac.compare_digest(expected, signature)
Compare signatures with a constant-time function, never with ===. Use timingSafeEqual, hash_equals or compare_digest. A plain equality check returns as soon as it hits a mismatched byte, which leaks how many leading bytes matched. You can reproduce the signature by hand with the HMAC generator: paste header.payload as the message and the secret as the key, then Base64URL-encode the result.
In production, verify tokens with a maintained library. Use jose for JavaScript, firebase/php-jwt for PHP, or PyJWT for Python. Algorithm pinning, clock skew and key rotation are each easy to get wrong in hand-written code, and these libraries already handle them.
HS256 vs RS256: shared secret or key pair
| HS256 | RS256 | |
|---|---|---|
| Type | Symmetric (HMAC-SHA256) | Asymmetric (RSA + SHA-256) |
| Keys | One shared secret | Private key signs, public key verifies |
| Who can create tokens | Anyone with the secret | Only the private key holder |
| Signature size | 32 bytes | 256 bytes for a 2048-bit key |
| Typical use | Single service that both issues and checks tokens | Identity provider issuing tokens to many services |
The trade-off is in the third row. With HS256, every service that verifies tokens can also forge them, because verification needs the same secret. Give that secret to five microservices and it has five places to leak from. With RS256 (or ES256, which uses elliptic curves and produces shorter signatures), the private key stays on the issuer and you publish the public key, often through a JWKS endpoint (a URL that serves the issuer’s public keys). That is why OAuth providers such as Auth0, Okta and Keycloak default to asymmetric algorithms. For a single service that issues and checks its own tokens, we still recommend HS256. It is simpler, and there are no keys to distribute.
exp, iat and nbf are Unix timestamps in seconds
Three registered claims deal with time. All of them are Unix timestamps in seconds, not milliseconds. Mixing the two up issues tokens that expire tens of thousands of years in the future.
iat(issued at): when the token was created, useful for revoking every token older than a certain point.exp(expiration): the token is invalid at or after this time.nbf(not before): the token is invalid before this time, and it is rarely used outside scheduled access.
In the example token, iat is 1757462400 and exp is 1757466000, one hour later. Paste those numbers into the Unix timestamp converter to see the dates, or let the JWT decoder show them next to the claims. A small helper for the check:
function isExpired(payload, leewaySeconds = 60) {
const now = Math.floor(Date.now() / 1000);
if (typeof payload.exp !== "number") return true; // no expiry: reject
return payload.exp + leewaySeconds < now;
}
A leeway of 30 to 60 seconds tolerates clock drift between servers. A much larger leeway has the same effect as issuing longer-lived tokens.
Common JWT security mistakes
Accepting alg: none lets anyone forge a token
The JWT spec allows {"alg":"none"} for unsigned tokens. Early libraries trusted the header and skipped verification when they saw it. An attacker could decode a token, change sub, set the algorithm to none, drop the signature, and be accepted as another user. The fix is to hard-code the algorithms your server accepts and reject everything else.
Algorithm confusion turns the public key into a secret
This attack targets servers that accept both HS256 and RS256. The attacker takes your RSA public key, which is public by design, uses it as an HMAC secret, and signs with HS256. A library that picks the algorithm from the header then verifies the token with the public key as the secret. The fix is the same: pin the algorithm.
Signing secrets in the frontend are public
If a browser app can sign tokens, so can anyone who opens DevTools. HMAC secrets and private keys belong on the server. The JWT generator is fine for producing test tokens, but real ones should come from your backend.
A token with no expiry is valid forever
A token without exp stays valid after the user logs out and after they change their password. Give access tokens a short life (minutes to an hour) and use refresh tokens for longer sessions.
Sensitive data in the payload is readable
Anyone holding the token can read the payload. In practice that includes browser extensions, proxy logs and anyone who finds the token in a crash report. Keep the payload to identifiers and roles.
Weak HS256 secrets can be brute-forced
An HS256 token signed with a secret like secret or password123 can be brute-forced offline in seconds with a tool such as hashcat, and the attacker needs only one valid token to test guesses against. Use at least 32 random bytes from a cryptographic source. A secret that a person could type from memory is too short.