How JSON Web Tokens Work: Claims, Signatures and Common Mistakes
A JSON Web Token (JWT) is three Base64URL-encoded strings joined by dots: a header that names the signing algorithm, a payload of claims, and a signature over the first two. Anyone holding the token can decode and read the header and payload; only the signature, checked with the right key, proves the token came from the issuer and was not altered. A server should accept a token only after it has verified that signature with an algorithm it chose itself and has checked the expiry, issuer and audience claims.
The three parts of a token
The format is defined in RFC 7519 (JWT) and RFC 7515 (JSON Web Signature, JWS). Here is a real HS256 token, generated with Node.js for this guide:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyLTEyMzQiLCJhdWQiOiJodHRwczovL2FwaS5leGFtcGxlLmNvbSIsImlhdCI6MTc2NzIyNTYwMCwibmJmIjoxNzY3MjI1NjAwLCJleHAiOjE3NjcyMjY1MDAsImp0aSI6IjVmMWMyYTllIn0
.GDf2WqMmWcMi-OASUkdUs3sZiR74b7bvCDamGJcAzy0
It is split over three lines here for readability; the real token is one line with no spaces. Decoding the first two segments gives:
// header
{"alg":"HS256","typ":"JWT"}
// payload
{"iss":"https://auth.example.com","sub":"user-1234","aud":"https://api.example.com",
"iat":1767225600,"nbf":1767225600,"exp":1767226500,"jti":"5f1c2a9e"}
The third segment is the HMAC-SHA256 of the ASCII string header.payload (the two encoded segments and the dot between them), itself Base64URL-encoded. It was produced like this:
const crypto = require('crypto');
const b64u = (s) => Buffer.from(s).toString('base64url');
const header = b64u(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const payload = b64u(JSON.stringify({
iss: 'https://auth.example.com', sub: 'user-1234', aud: 'https://api.example.com',
iat: 1767225600, nbf: 1767225600, exp: 1767226500, jti: '5f1c2a9e'
}));
const secret = 'correct-horse-battery-staple-7f3a91c2e4b8d605'; // demo only
const signature = crypto.createHmac('sha256', secret)
.update(header + '.' + payload)
.digest('base64url');
console.log(header + '.' + payload + '.' + signature);
Base64URL, not plain Base64
Each segment uses the URL-safe Base64 alphabet from RFC 4648 section 5: - replaces +, _ replaces /, and the trailing = padding is dropped. The same two bytes 0xFB 0xFF encode as +/8= in standard Base64 and -_8 in Base64URL. That is why a token can go into a URL or a header without further escaping, and why a standard Base64 decoder may reject a segment until you swap the characters back and re-pad it. The Base64 guide covers the encoding in more detail, and the Base64 Encoder / Decoder handles both alphabets.
Header fields: alg, typ and kid
| Field | Meaning |
|---|---|
alg | The algorithm used to sign the token, for example HS256, RS256 or ES256. Required in JWS. |
typ | The media type of the whole token; JWT is the conventional value. Optional. |
kid | Key ID: a hint telling the verifier which of several keys was used, which makes key rotation possible. Optional, and its value is chosen by the issuer. |
The header is just as untrusted as the payload until the signature has been checked. Treat kid as a lookup key into a set of keys you already hold, never as a file path or database query to run as-is.
Registered claims
RFC 7519 registers seven claim names. All are optional at the format level; your application decides which it requires.
| Claim | Name | What to check |
|---|---|---|
iss | Issuer | Equals the issuer you expect, compared exactly. |
sub | Subject | The user or entity the token is about, unique within the issuer. |
aud | Audience | A string or an array of strings; your service must be one of them, otherwise reject. |
exp | Expiration time | Reject the token on or after this time. |
nbf | Not before | Reject the token before this time. |
iat | Issued at | When the token was created; useful for enforcing a maximum age. |
jti | JWT ID | A unique identifier, useful for detecting replays or for a deny-list. |
exp, nbf and iat are NumericDate values: the number of seconds since 1970-01-01T00:00:00Z, ignoring leap seconds. In the example, iat 1767225600 is 2026-01-01T00:00:00Z and exp 1767226500 is fifteen minutes later. The RFC allows a small leeway, usually no more than a few minutes, to allow for clock skew between servers. The Unix Timestamp Converter turns these numbers into dates.
HS256 versus RS256 and ES256
| Algorithm | Type | Signs with | Verifies with |
|---|---|---|---|
HS256 | HMAC with SHA-256 | Shared secret | The same shared secret |
RS256 | RSASSA-PKCS1-v1_5 with SHA-256 | RSA private key | RSA public key |
ES256 | ECDSA, P-256 curve, SHA-256 | EC private key | EC public key |
With HS256, every service that can verify a token can also mint one, because verification and signing use the same secret. That is fine when one service both issues and checks tokens. When several services or third parties need to verify tokens, an asymmetric algorithm is the better fit: only the issuer holds the private key, and verifiers fetch the public key, often from a JSON Web Key Set (JWKS) endpoint, selecting the right key by kid. The algorithms themselves are specified in RFC 7518.
How verification works
- Split the token on dots; a signed JWT has exactly three segments.
- Decode the header and confirm
algis the algorithm you configured for this issuer. Do not let the token choose. - Recompute the signature over the original encoded
header.payloadstring (not re-serialised JSON) using the key you hold, and compare it with the third segment, ideally in constant time. - Only then parse the payload and check
exp,nbf,issandaud, plus anything your application requires.
Run against the example token with the demo secret and a clock set to 2026-01-01T00:06:40Z, a verifier written this way accepts it. Changing sub to admin without re-signing, using a different secret, or moving the clock past exp all cause rejection. In production, use a maintained library rather than hand-written checks, and configure its allowed algorithms, issuer and audience explicitly.
What a decoder can and cannot tell you
Decoding is not verifying. A decoder reads the header and payload, which requires no key at all. It cannot tell you whether the token is genuine, whether it has been revoked, or whether the server that receives it will accept it.
The JWT Decoder on this site works like this, all in your browser:
- Decode splits the token, Base64URL-decodes the header and payload and shows them as indented JSON under
// HEADERand// PAYLOAD. Anything other than exactly three dot-separated parts is reported as an invalid JWT, with the number of parts found. - Every numeric
exp,iatornbfvalue in the payload gets a comment with the date in ISO 8601 UTC form, for example// 2026-01-01T00:15:00.000Z. It shows the date only; it does not say whether the token has expired, so compare it with the current time yourself. - Verify Signature recomputes the HMAC with the Web Crypto API for
HS256,HS384andHS512only. For any otheralg, includingRS256,ES256andnone, it says verification is not supported. The secret you type is used as UTF-8 text, so if your server's key is a Base64-encoded byte string that it decodes before use, pasting the encoded text will not match.
Paste the example token, enter the demo secret and the tool reports the signature as valid, even though the token expired on 1 January 2026. That is the point of this section: a valid signature is necessary, not sufficient.
Common mistakes
Trusting alg from the token
Early libraries read alg from the header and verified accordingly. An attacker could then send "alg":"none" with an empty signature, or, against a server expecting RS256, switch to HS256 and sign with the server's public key as the HMAC secret. Always pin the expected algorithm, or list of algorithms, on the server. RFC 8725, the JWT Best Current Practices document, covers both attacks.
Putting secrets or personal data in the payload
A signed JWT is encoded, not encrypted. Passwords, API keys and personal data placed in claims can be read by anyone who sees the token, including browser extensions, proxies and log files. Keep claims to identifiers and permissions.
Weak HMAC secrets
An HS256 token plus a guessable secret can be brute-forced offline, because the attacker can test candidate secrets without contacting your server. RFC 7518 requires a key at least as long as the hash output, so 256 bits for HS256. Generate one randomly, for example with crypto.randomBytes(32), rather than choosing a phrase.
Not checking exp, aud or iss
A correctly signed token from your identity provider for a different application will pass a signature-only check. Validate aud and iss every time, and require exp.
Milliseconds instead of seconds
JavaScript's Date.now() returns milliseconds. Writing it straight into exp creates a token that expires tens of thousands of years from now: the decoder shows 1767226500000 as +057971-03-07T10:00:00.000Z. Use Math.floor(Date.now() / 1000).
Long-lived tokens with no way to revoke them
A JWT stays valid until it expires, even after the user logs out or is disabled, unless the server keeps some state. Common approaches are short access-token lifetimes (minutes) paired with refresh tokens that the server can revoke, or a deny-list keyed on jti.
JWS versus JWE
Almost every token called a JWT is a JWS: signed, readable by anyone. JSON Web Encryption (RFC 7516) encrypts the content instead, and its compact form has five dot-separated parts rather than three. Use JWE when the claims must be confidential from the client or intermediaries. The decoder here handles only three-part signed tokens.
Frequently asked questions
Can I read a JWT without the secret?
Yes. The header and payload are only Base64URL-encoded, so any decoder can show them. The secret or public key is needed only to check the signature.
Is it safe to paste a production token into an online decoder?
A live token is a credential for as long as it is valid. The decoder on this site runs in your browser and does not send the token anywhere, but it is still good practice to use expired or test tokens when debugging.
How long should a JWT last?
There is no single rule. Access tokens are commonly kept short, from a few minutes to an hour, because they cannot easily be revoked; longer sessions are handled with refresh tokens the server can invalidate.
Why does my token fail with a valid signature?
Usually a claim check: exp in the past, nbf in the future because of clock skew, or an aud or iss value that does not match what the verifying service expects.