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.

Sample token
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyXzgyMzEiLCJhdWQiOiJodHRwczovL2FwaS5leGFtcGxlLmNvbSIsImlhdCI6MTc5MDg0NTIwMCwiZXhwIjoxNzkwODQ4ODAwLCJzY29wZSI6InJlYWQ6aW52b2ljZXMifQ.ox2BsidWH9FCqAt4E8oSj1e-sqpqxaIg4mowpt-B4qE

The 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. alg is the signing algorithm and typ is the token type. Some headers also have kid, 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

  1. Open the JWT Decoder and paste the token. A leading Bearer and stray spaces or line breaks are removed for you.
  2. Read the Header and Payload panels, which show the decoded JSON.
  3. Check Time claims, where iat, nbf and exp are 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:

Header
{
  "alg": "HS256",
  "typ": "JWT"
}
Payload
{
  "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.

ClaimMeaningIn the sample token
issIssuer: who created the tokenhttps://auth.example.com
subSubject: who the token is about, usually a user IDuser_8231
audAudience: which API or app should accept ithttps://api.example.com
iatIssued at: when it was created (seconds since 1970, UTC)1790845200 = 2026-10-01 09:00:00 UTC
expExpiration: reject the token at or after this time1790848800 = 2026-10-01 10:00:00 UTC
nbfNot before: reject the token before this timenot used in this token
jtiJWT ID: a unique ID, useful for revocation listsnot 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 1790848800000 is almost always a bug from code that used Date.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: exp is in the past. Request a new token, usually through your refresh flow.
  • Not valid yet: nbf (or iat) 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 RS256 but the server expects HS256 (or the reverse), or the kid doesn'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 +%s on both machines, and check that time sync (NTP) is on.
  • Milliseconds: a 13-digit iat reads as a date thousands of years ahead. Divide by 1000 when the token is created.
  • Local time treated as UTC: if iat is 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:

Python (PyJWT)
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:

JavaScript (browser or Node.js 18+)
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 seconds
Python
import 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.