A JWT carries its own expiry inside the signed payload, and getting that expiry, its refresh flow, and its validation right is what separates a safe token from a forgeable one. A JSON Web Token is three base64url-encoded parts joined by dots: a header, a payload of claims, and a signature. The header and payload are only encoded, not encrypted, so anyone can read them. That is why the security lives entirely in the signature and in how carefully the verifier checks the time-based claims. This article covers what exp, nbf, and iat mean, why access and refresh tokens are separated, and the mistakes that repeatedly turn JWT auth into a hole.
What do exp, nbf, and iat actually mean?
They are the three registered time claims, and each is a NumericDate: an integer count of seconds since the Unix epoch, 1970-01-01 00:00:00 UTC. A verifier reads them from the payload and compares them against the current time.
| Claim | Name | Meaning |
|---|---|---|
exp |
Expiration Time | The token must be rejected at or after this instant. |
nbf |
Not Before | The token must be rejected before this instant. |
iat |
Issued At | When the token was created; used for age checks and auditing. |
A decoded payload might look like this:
{
"sub": "1024",
"name": "Ada",
"iat": 1735689600,
"nbf": 1735689600,
"exp": 1735693200
}
Here the token was issued and became valid at the same second and expires 3,600 seconds (one hour) later. All three claims are optional in the spec, but a token without exp effectively never expires, which is almost never what you want. Because these are absolute UTC timestamps, servers must have reasonably synchronised clocks; verifiers usually allow a small leeway (a few seconds) to absorb clock skew rather than rejecting a token that is off by a second. You can paste a token into the JWT decoder to read these values as human-readable dates, which makes an off-by-one-hour timezone bug or a missing exp obvious at a glance.
Why split into access tokens and refresh tokens?
Because the two jobs have opposite requirements: an access token needs to travel constantly, while a refresh token needs to be guarded. An access token is presented on every API call, typically in an Authorization: Bearer header. The more often a secret is sent, the more chances it has to leak through logs, proxies, or a compromised endpoint. So access tokens are made short-lived, often minutes to an hour, limiting the window in which a stolen one is useful.
A refresh token solves the resulting friction. Instead of forcing the user to log in again every hour, the client keeps a longer-lived refresh token and sends it to a dedicated token endpoint to mint a fresh access token when the old one expires.
| Property | Access token | Refresh token |
|---|---|---|
| Lifetime | Short (minutes–1 hour) | Long (days–weeks) |
| Sent to | Every protected API | Only the auth/token endpoint |
| Frequency of use | Constant | Rare |
| If leaked | Limited window | High impact — treat as a credential |
Because the refresh token is high value, it is stored more carefully (for example an HttpOnly, Secure cookie rather than JavaScript-readable storage) and can be rotated: each refresh issues a new refresh token and invalidates the old one, so a stolen-and-replayed refresh token can be detected when the legitimate client later presents the now-invalid version. The split also gives you a revocation lever the stateless access token lacks — you keep refresh-token state server-side, so cutting off a session means refusing to honour its refresh token.
How does a verifier decide a token is valid?
Verification is a fixed sequence, and skipping any step is where bugs live. A correct verifier does all of the following before trusting a single claim:
- Split the token and base64url-decode the header and payload.
- Read the
algfrom the header but do not trust it blindly — compare it against the algorithm you expect. - Recompute the signature over
header.payloadwith the correct key and confirm it matches the third segment. - Check
expis in the future andnbf(if present) is in the past, allowing small leeway. - Validate
iss(issuer) andaud(audience) match what this service accepts.
Only after the signature checks out do the claims mean anything. A decoded-but-unverified payload is just attacker-controllable JSON. This is the single most important mental model for JWTs: reading a token and trusting a token are completely different acts.
What are the common JWT mistakes?
Most JWT failures are a handful of recurring errors, and each has a direct fix.
Issuing tokens with no expiry
A token without an exp claim is valid forever. If it leaks, there is no natural point at which it stops working, and because a plain JWT is stateless you often cannot revoke it either. Always set exp, keep it short, and use refresh tokens to extend sessions rather than issuing long-lived access tokens.
Accepting alg:none or a caller-chosen algorithm
The none algorithm means “unsigned”. Some libraries historically honoured it, which let an attacker send a token with no signature and any payload they liked. A related attack swaps a token signed with an RSA public key to appear as an HMAC token, tricking a verifier into using the public key as an HMAC secret. The defence is the same for both: pin the expected algorithm on the server and reject anything else, including none. Never let the token’s own header decide how it will be verified.
Storing tokens in localStorage
Anything in localStorage is readable by any JavaScript running on the page, so a single cross-site scripting flaw hands an attacker the token. For browser apps, an HttpOnly, Secure, SameSite cookie keeps the token out of reach of page scripts. If you must use localStorage, understand that you are betting the token’s safety on having zero XSS, which is a hard bet to win.
Trusting claims without verifying the signature
Reading role: admin from a decoded payload and acting on it, without checking the signature, lets anyone grant themselves any claim they type. Because the payload is just base64url, forging one is trivial. Every claim you rely on for authorization must come from a token whose signature you have verified with your key.
Putting secrets in the payload
The payload is encoded, not encrypted. Do not place passwords, full personal records, or API secrets in a JWT, because anyone who holds the token can read them. Put an identifier in the token and keep the sensitive data server-side.
How can I inspect a token safely?
Decode it, but never paste a production token into a tool that sends it anywhere. Decoding a JWT is pure base64url and JSON parsing, so it can happen entirely in your browser with no network call. That matters for tokens because they are live credentials: pasting one into a server-side “JWT viewer” means handing your access token to whoever runs that server. The JWT decoder runs in the page, so the token stays on your machine while you read its header, its claims, and its exp and iat as real dates. Use it to confirm the expiry is set and sensible, to check the algorithm matches what your service expects, and to spot a payload carrying data that should never have been in a token.
Key takeaways
JWT security is mostly discipline around a few facts. The token is readable by anyone, so its integrity depends entirely on a verified signature. The exp, nbf, and iat claims are UTC-epoch timestamps that let a verifier reason about time, and exp should always be present and short. Splitting access and refresh tokens limits blast radius when a token leaks and gives you a revocation path. And the classic mistakes — no expiry, alg:none, storing tokens where scripts can read them, and trusting unverified claims — are all avoidable with a strict, algorithm-pinned verification step. Decode freely to understand a token; trust it only after you have checked the signature.