Developer Guides
How to Read a JWT: Header, Claims and Expiry Explained
Published
A JSON Web Token (JWT) is the string you see in an Authorization: Bearer … header. When an API answers with 401 Unauthorized, the reason is very often written inside the token itself: it expired, it was issued for a different API, or it is not valid yet. This guide shows how to read a token, what each field means, and why reading a token is not the same as trusting it.
Every example below uses the same sample token, so you can paste it into the JWT Decoder and follow along. It is signed with a throwaway secret and expired on 2026-10-01 10:00:00 UTC.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyXzgyMzEiLCJhdWQiOiJodHRwczovL2FwaS5leGFtcGxlLmNvbSIsImlhdCI6MTc5MDg0NTIwMCwiZXhwIjoxNzkwODQ4ODAwLCJzY29wZSI6InJlYWQ6aW52b2ljZXMifQ.ox2BsidWH9FCqAt4E8oSj1e-sqpqxaIg4mowpt-B4qEThe three parts of a JWT
A signed JWT is three base64url-encoded segments separated by dots: header.payload.signature.
- Header (
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9): metadata about the token.algis the signing algorithm andtypis the token type. Some headers also havekid, which tells the server which key to verify with. - Payload: the claims, the actual data such as user ID, audience and expiry.
- Signature: proof that the header and payload were created by someone holding the signing key and haven't been changed since.
Base64url is ordinary Base64 with - and _ instead of + and /, and with the trailing = padding removed. That's why some generic Base64 decoders fail on JWTs. Decoding is just reversing that encoding: no key is needed, and anyone holding the token can read it.
Decode it in your browser
- Open the JWT Decoder and paste the token. A leading
Bearerand stray spaces or line breaks are removed for you. - Read the Header and Payload panels, which show the decoded JSON.
- Check Time claims, where
iat,nbfandexpare converted to UTC dates and marked as expired, not yet valid, or active.
The decoder runs entirely in your browser with JavaScript; the token isn't sent to our server. For the sample token you should see this:
{
"alg": "HS256",
"typ": "JWT"
}{
"iss": "https://auth.example.com",
"sub": "user_8231",
"aud": "https://api.example.com",
"iat": 1790845200,
"exp": 1790848800,
"scope": "read:invoices"
}What each claim means
The JWT standard (RFC 7519) defines a few registered claims. Everything else, like scope in the sample, is defined by whoever issues the token.
| Claim | Meaning | In the sample token |
|---|---|---|
iss | Issuer: who created the token | https://auth.example.com |
sub | Subject: who the token is about, usually a user ID | user_8231 |
aud | Audience: which API or app should accept it | https://api.example.com |
iat | Issued at: when it was created (seconds since 1970, UTC) | 1790845200 = 2026-10-01 09:00:00 UTC |
exp | Expiration: reject the token at or after this time | 1790848800 = 2026-10-01 10:00:00 UTC |
nbf | Not before: reject the token before this time | not used in this token |
jti | JWT ID: a unique ID, useful for revocation lists | not used in this token |
aud can be a single string or an array of strings. A server should only accept a token whose audience includes its own identifier.
Reading iat, nbf and exp
Time claims are "NumericDate" values: whole seconds since 1970-01-01 00:00:00 UTC. In the sample, 1790848800 is 2026-10-01 10:00:00 UTC, exactly one hour after iat. You can check any value with the Unix timestamp converter.
- Times are UTC. If a token "expired early", compare against UTC rather than your local clock.
- Seconds, not milliseconds. A 13-digit value such as
1790848800000is almost always a bug from code that usedDate.now()without dividing by 1000. The decoder flags values like this. - Clocks drift. Servers commonly allow a small leeway, often under a minute, so a token issued a moment ago isn't rejected as "not valid yet" because two clocks disagree slightly.
Common reasons a token is rejected
- Expired:
expis in the past. Request a new token, usually through your refresh flow. - Not valid yet:
nbf(oriat) is in the future, usually because of a clock problem on the issuing or receiving server. - Wrong audience or issuer: the token was issued for a different API (
aud) or by a different identity provider (iss) than the server expects. This is common when staging and production tokens get mixed up. - Wrong algorithm or key: the header says
RS256but the server expectsHS256(or the reverse), or thekiddoesn't match any key the server knows. - Damaged token: it was copied with quotes or cut short. A complete signed JWT always has exactly two dots.
What "the JWT was issued in the future" means
This error means the server checking the token compared its own clock with the token's iat (or nbf) and found the token was created after "now". The token itself is usually fine. The two machines disagree about the time, or the timestamp was written wrongly.
- Clock skew: the issuing server's clock runs ahead, or the receiving server's clock runs behind. Containers and virtual machines that resume after sleep are common culprits. Compare
date -u +%son both machines, and check that time sync (NTP) is on. - Milliseconds: a 13-digit
iatreads as a date thousands of years ahead. Divide by 1000 when the token is created. - Local time treated as UTC: if
iatis ahead by a whole number of hours, the issuer probably built it from a local date. Paste the value into the Unix timestamp converter to see the exact offset.
Fix the clock or the code that sets the claim. A small leeway is reasonable (for example clockTolerance in Node's jsonwebtoken, leeway in PyJWT, or JWT::$leeway in firebase/php-jwt), but keep it to seconds. Turning the time checks off hides the real problem.
Decoding is not verifying
Anyone can decode a token and edit the payload, so the claims mean nothing until the signature is checked with the right key. Servers must verify the signature, then check exp, nbf, aud and iss, before trusting anything inside. They should also accept only the algorithms they expect: tokens with "alg": "none", or with an algorithm the server didn't choose, should be refused.
Here's what verification looks like in Python with the PyJWT library, using the sample token and its throwaway secret:
import jwt # PyJWT
try:
claims = jwt.decode(
token,
"demo-secret-for-docs-only", # the key the issuer signed with
algorithms=["HS256"], # never let the token choose
audience="https://api.example.com",
)
except jwt.ExpiredSignatureError:
print("Rejected: token has expired")
except jwt.InvalidTokenError as error:
print(f"Rejected: {error}")
else:
print("Valid for", claims["sub"])Run against the sample, this prints Rejected: token has expired. With a different secret, PyJWT raises InvalidSignatureError instead. A decoder that only reads the token, including ours, can't tell you either of these things; only verification with the key can.
Decode a JWT in code
To read claims in a script, for example to log when a token expires, you only need the standard library:
function decodeJwtPart(part) {
// base64url -> base64, then restore the padding JWTs leave out
const base64 = part.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(part.length / 4) * 4, "=");
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
return JSON.parse(new TextDecoder().decode(bytes)); // handles non-ASCII text correctly
}
const [header, payload] = token.split(".").slice(0, 2).map(decodeJwtPart);
console.log(new Date(payload.exp * 1000).toISOString()); // exp is in secondsimport base64, json
from datetime import datetime, timezone
def decode_jwt_part(part):
padded = part + "=" * (-len(part) % 4)
return json.loads(base64.urlsafe_b64decode(padded))
header, payload = (decode_jwt_part(p) for p in token.split(".")[:2])
print(datetime.fromtimestamp(payload["exp"], tz=timezone.utc))Both print 2026-10-01 10:00:00 for the sample token. Remember that these only read the token; they don't verify it.
Keep tokens safe while debugging
- A valid token works like a password until it expires. Prefer expired tokens or tokens from test environments when you need to inspect one.
- Don't paste live tokens into tickets, chat or screenshots.
- The payload is only encoded, not encrypted. Never put secrets or personal data in a JWT that you wouldn't want the token holder to read.
Frequently asked questions
Can I decode a JWT without the secret key?
Yes. The header and payload are only base64url-encoded, so no key is needed to read them. You need the key (or the issuer's public key) to verify the signature.
Why does my token have five parts instead of three?
A token with five dot-separated segments is an encrypted JWT (JWE). Its contents can only be read with the decryption key, so a decoder for signed tokens can't display them.
Is it safe to paste a token into the JWT Decoder?
The decoder runs in your browser and doesn't send the token to our server. Even so, treat live tokens as credentials and use expired or test tokens where you can.
Related tools: Base64 Decoder, JSON Formatter and Unix Timestamp Converter.
Related tools and guides
Ready to try it? Open the JWT Decoder tool, or browse all developer tools.