Reading a JWT by hand: exp, iat, nbf and the traps
Updated 2026-08-09
Every JWT debugging session eventually reaches the point where the library's error message ('invalid token') tells you nothing and you have to take the thing apart by hand. The good news: a JWT is just two JSON documents and a MAC, glued together with dots and encoded in a base64 variant. Fifteen minutes with Python and openssl demystifies the whole format permanently.
This article builds a real HS256 token from scratch, verifies its signature two independent ways, and walks through the places people actually get bitten: base64url padding, millisecond timestamps in exp, nbf failures from clock skew, and the historical alg=none hole.
Three segments, two dots
A JWT (RFC 7519) is header.payload.signature, each segment base64url-encoded. The header and payload are JSON; the signature is raw bytes. Here is a complete, valid HS256 token we will dissect for the rest of this article:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 .eyJzdWIiOiJ1c2VyLTQyIiwibmFtZSI6IkFkYSBMb3ZlbGFjZSIsImlhdCI6MTc4NjI3NjgwMCwibmJmIjoxNzg2Mjc2ODAwLCJleHAiOjE3ODYyODA0MDB9 .2XiSe9GDPRp5np_sqU4AAezDnEZqChRm5eeYS8S6tcQ
A useful reflex: any JWT header you will ever see starts with eyJ, because that is base64url for {" — the opening of a JSON object. If your 'JWT' does not start with eyJ, you are probably looking at an opaque session token or a Base64 blob of something else entirely. Two dots means three segments; anything else is not a JWS compact serialization.
Building it from scratch in Python
import base64, hashlib, hmac, json
def b64url(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
header = {"alg": "HS256", "typ": "JWT"}
payload = {"sub": "user-42", "name": "Ada Lovelace",
"iat": 1786276800, "nbf": 1786276800, "exp": 1786280400}
h = b64url(json.dumps(header, separators=(",", ":")).encode())
p = b64url(json.dumps(payload, separators=(",", ":")).encode())
secret = b"a-string-secret-at-least-256-bits-long"
sig = hmac.new(secret, f"{h}.{p}".encode(), hashlib.sha256).digest()
token = f"{h}.{p}.{b64url(sig)}"
# -> the exact 201-character token shown aboveThree details matter. The MAC is computed over the encoded string 'header.payload', not over the raw JSON — re-serializing the JSON with different key order or whitespace produces a different signature even though the claims are identical. The separators argument strips whitespace, matching what real libraries emit. And the padding is stripped: JWT segments never contain '='.
To decode by hand, reverse it: split on dots, restore padding to a multiple of 4, base64url-decode, parse as UTF-8 JSON. That is essentially all our JWT decoder tool does — split, pad, decode, and pretty-print with the time claims annotated.
base64url is not base64
The single most common cause of 'invalid token' when hand-rolling: feeding a JWT segment to a standard base64 decoder. RFC 4648 section 5 defines the URL-safe alphabet with two substitutions and optional padding:
| Standard base64 | base64url (JWT) | |
|---|---|---|
| Character 62 | + | - |
| Character 63 | / | _ |
| Padding | = required | stripped in JWTs |
standard : 2XiSe9GDPRp5np/sqU4AAezDnEZqChRm5eeYS8S6tcQ= base64url: 2XiSe9GDPRp5np_sqU4AAezDnEZqChRm5eeYS8S6tcQ # extreme case, bytes fb ef be: standard : ++++ base64url: ----
Our signature happens to contain a '/' in standard encoding, which becomes '_' in the token — and the trailing '=' vanishes. When restoring padding, append '=' until the length is a multiple of 4. A valid segment length mod 4 is never 1; if you hit that, the token was truncated in transit (log line clipping and clipboard mangling are the usual suspects).
exp, iat, nbf: unix seconds and the millisecond bug
The three registered time claims are NumericDate values: seconds since 1970-01-01 UTC. In our specimen, iat and nbf are 1786276800 (Sun, 09 Aug 2026 12:00:00 UTC) and exp is 1786280400 (13:00:00 UTC) — a one-hour token. Per RFC 7519, exp is exclusive (the token must be rejected at or after that instant) while nbf is inclusive (valid at exactly nbf). iat is informational: it records when the token was minted and is not a validity bound by itself, though servers sometimes use it to enforce a maximum age.
- Milliseconds bug: JavaScript's Date.now() returns milliseconds. Divide by 1000 before putting it in exp, or your token 'expires' in the year 58,000+ and any max-age check rejects it. If a decoded exp has 13 digits, this is your bug.
- Clock skew: a token minted by server A with nbf = now can be rejected by server B whose clock is two seconds behind. RFC 7519 explicitly allows a small leeway (usually a minute or two); most libraries expose it as clockTolerance or leeway. Sporadic 'token not yet valid' errors are almost always this.
- Timezone red herring: NumericDate is UTC by definition. There is no timezone in a JWT timestamp; if your times look off by hours, the bug is in your display code, not the token.
Verifying the signature two independent ways
Never trust one implementation to check itself. The same signature derived with openssl, taking standard base64 output and translating it to base64url with tr:
printf '%s' "$HEADER_B64.$PAYLOAD_B64" \ | openssl dgst -sha256 -hmac 'a-string-secret-at-least-256-bits-long' -binary \ | openssl base64 | tr '+/' '-_' | tr -d '=' # -> 2XiSe9GDPRp5np_sqU4AAezDnEZqChRm5eeYS8S6tcQ (matches)
If your language's JWT library rejects a token that openssl validates, you have a padding, whitespace, or key-encoding problem, not a cryptography problem. The most common variant: the secret is itself stored base64-encoded in config, and one side decodes it before use while the other feeds the base64 string directly to the HMAC.
alg is attacker-controlled: none and key confusion
The header says which algorithm to use — and the header arrives from the client. In March 2015, Tim McLean published two bug classes that affected major JWT libraries (node-jsonwebtoken, pyjwt, php-jwt among them). First, several libraries honored {"alg":"none"} — a legitimate spec value for pre-secured contexts — and accepted tokens with an empty signature segment as valid. The forged header encodes to eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0, and the token simply ends with a trailing dot. Second, algorithm confusion: libraries that verified with 'whatever key the app supplied' could be tricked into checking an HS256 token using the server's RSA public key as the HMAC secret — and public keys are, by definition, public.
Modern libraries require an explicit algorithm allowlist, and RFC 8725 (the JWT Best Current Practices) codifies the lesson: validate the algorithm against what you expect, never against what the token declares. When debugging, always read the header's alg field first — an unexpected value there is a finding, not a curiosity.
Why our decoder does not verify signatures
The JWT decoder on this site decodes the header and payload, flags exp against your current clock, and displays the signature as an opaque base64url string with an explicit note that it cannot be verified client-side. That is a deliberate limit, not a missing feature. Verifying HS256 requires the shared secret, and a tool that invites you to paste production signing secrets into a web page is teaching you a terrible habit — even ours, which never sends your token over the network. RS256 verification with a public key would be technically feasible in-browser, but we have not implemented it; if you need real verification, use your JWT library on a machine you control, or the openssl one-liner above.
Two smaller limits worth knowing: the decoder expects the payload to be a JSON object, so the rare JWT carrying a non-JSON payload (the spec permits any octet sequence) will not decode, and the expired/valid badge trusts your local system clock. For converting the raw timestamps yourself, the unix timestamp converter does seconds and milliseconds in both directions, and the Base64 and HMAC tools cover the individual building blocks when you want to reproduce a segment by hand.
Questions people ask
Is it safe to paste a real production JWT into an online decoder?
Assume no. A JWT's payload is readable by anyone who holds the token, and many online decoders send input to a server. Ours decodes entirely in your browser with no network call, but the safe habit is to treat live tokens as credentials: debug with expired or freshly minted test tokens whenever possible.
My exp claim has 13 digits. Is the token broken?
It was minted with milliseconds instead of seconds — typically an undivided Date.now() in JavaScript. Compliant validators may treat it as valid for tens of thousands of years or reject it outright depending on their max-age logic. Fix the minting code; do not add leeway.
Why does base64-decoding a JWT segment fail in my language's standard decoder?
Two reasons: JWT uses the URL-safe alphabet (- and _ instead of + and /), and padding is stripped. Either use a base64url function, or translate the characters and append = until the length is a multiple of 4.
Can the server revoke a JWT before exp?
Not through the token itself — that statelessness is the point and the drawback. Revocation requires server-side state: a denylist of jti values, a per-user token-version claim checked against a database, or short exp values paired with refresh tokens. If instant revocation is a hard requirement, plain server-side sessions may fit better.