All posts
VibeZero Team10 min read

JWT expiration, explained

The exp claim is optional, logging out does not invalidate a token, and a stolen JWT is valid until it expires. What to set, and what to check instead.

  • security
  • vibe coding
  • engineering

Here is the sentence that surprises people, quoted from the specification that defines JWTs. RFC 7519 section 4.1.4 introduces the exp claim as the time “on or after which the JWT MUST NOT be accepted for processing”, and then says: “Use of this claim is OPTIONAL.”

So is nbf. So is iat. A token with no expiry is a fully conformant JWT, and a library handed one will verify the signature, find nothing to compare against a clock, and report success. Forever.

That is the first of three separate failures that all get filed under “token expiry”, and they need separating because they have different fixes and only one of them is about picking a number.

Three failures wearing one name

The token has no exp. Common in service-to-service tokens, in anything generated by a script somebody wrote once, and in code that used a signing helper without passing an options object. The token is a bearer credential with no end date, which makes it a password that cannot be changed and is written down in every log, proxy and error report it has ever passed through.

The verifier does not check exp. Subtler, more common, and it arrives in two forms.

The first is reading a token where you meant to verify one. jwt.decode() in Node’s jsonwebtoken and jwtDecode from the jwt-decode package parse a token and hand back the payload, checking no signature and no clock. There is also the deliberate opt-out, options={"verify_exp": False} in PyJWT, which typically enters a codebase during a debugging session and stays.

The second survives code review, because the correct verifying call is right there in the diff. Checking expiry means checking it if the claim is present, so a token with no exp passes a fully correct verification without incident. Presence is a separate requirement that you have to ask for, and PyJWT’s own usage documentation demonstrates it with a token carrying sub and iat and no expiry, caught only by options={"require": ["exp"]}. That is why the first failure in this list and this one compose so neatly: the issuer omitted the claim, the verifier had nothing to compare, and every line of code involved looks right.

exp is set, absurdly far away. A year, or ten. This is the failure that feels like a decision, and it usually is: someone got tired of being logged out, or a mobile client had no refresh flow, so the lifetime went up until the complaints stopped. The reasoning is sound and the result is that every token ever issued, including the one in the log file from March and the one in the laptop that was stolen, still works.

You cannot un-issue a signed token

Say a user reports their laptop stolen, or you spot a session doing something it should not. You click “sign out all devices”. What happened?

On a session-cookie system, a row was deleted and the next request fails. On a stateless JWT system, the client threw away a token that is still perfectly valid, and any copy an attacker took is unaffected. Nothing was revoked, because there is nothing to revoke: the token proves its own validity to any verifier holding the key, and none of those verifiers asks anyone’s permission.

Supabase documents both halves of this honestly. Signing out removes the session row from the database, which stops the refresh token from producing new access tokens, and the access token itself keeps working until it expires. That is the whole reason the default lifetime matters so much.

The clearest proof that live tokens outlive your control panel is in Supabase’s signing keys guide, in its advice for retiring an old key: “If your access token expiry time is configured to be 1 hour, wait at least 1 hour and 15 minutes before revoking the legacy JWT secret”, so that active users are not forcibly signed out. Read that from the attacker’s side. Revoking the key that signed a token does not stop the token, until every token signed by it has aged out. Your incident response window has a floor, and you set it when you chose the expiry.

The number, and where the real answer lives

Supabase’s default access token lifetime is 3600 seconds, one hour, with a documented maximum of 604800 seconds, one week. The docs recommend the default and discourage going above it. One hour is a good number for a browser app, and the reason it can be a good number is that the access token is not the thing keeping the user logged in.

The refresh token is. It is opaque rather than self-describing, it is stored server-side, and it is therefore the one credential in the system that can actually be cancelled. That split is the entire design:

  • The access token is short-lived and unrevocable. Its lifetime is your exposure window after a theft you have not detected yet.
  • The refresh token is long-lived and revocable. It is checked against a database row on every use, so deleting the row ends the session.

Rotation is what makes the second half work. Supabase enables enable_refresh_token_rotation by default and gives each refresh token a single use with a 10 second reuse interval to survive races and flaky networks. The security value is not the rotation itself, it is what rotation lets you detect: if a refresh token is presented twice outside that window, either the user or the thief is holding a copy, and the correct response is to kill the whole session family rather than to guess which one is real.

If you are building this yourself rather than buying it, the shape to copy is exactly that: short access tokens, rotating refresh tokens with reuse detection, and every refresh checked against a row you can delete.

What a stolen token is worth, measured in minutes

The reason to care about the exposure window is that token theft in a generated app is not exotic. The browser client that Lovable and Bolt projects ship persists the Supabase session in localStorage, which is readable by any script running on your origin. So the value of every DOM XSS and every stored XSS in your application is denominated in token lifetime.

This is the trade discussed in the CSRF post from the other direction: moving credentials out of cookies removes cross-site request forgery and moves the entire risk to the XSS side of the ledger. The consequence for this post is specific. With a one hour access token, an injection that runs once and exfiltrates whatever it finds gets an hour. With a one week token, it gets a week.

Now the uncomfortable part, which is easy to talk past. The Supabase client persists the whole session in localStorage, refresh token included, so the script that took your access token took the renewable credential beside it and can mint fresh access tokens indefinitely. On this stack, shortening exp alone buys you much less than it looks like it does. What actually caps the damage is the pair of controls from the previous section: reuse detection noticing that two parties are refreshing one token, and a session row you can delete. Expiry is what makes those controls bite quickly instead of eventually, and it is worth setting correctly for that reason rather than as a defense on its own.

Two smaller ones worth having for the same reason: keep the payload boring, since a JWT is signed and not encrypted and everything in it is disclosed to whoever holds it, and never put a token in a URL, where it lands in browser history, Referer headers and every access log between you and the user.

When statelessness is worth giving up

For most requests, the stateless check is right: verify the signature, read the claims, serve the response, touch no database. For a small set of actions it is not, and the deciding question is whether you would want to be able to stop the request mid-session.

Changing a password or email, deleting an account, moving money, granting another user a role, anything an administrator can do. For those, look the session up:

-- Supabase puts a session_id claim in the access token. It refers to a real row.
create or replace function public.session_is_live()
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
  select exists (
    select 1 from auth.sessions
    where id = (auth.jwt() ->> 'session_id')::uuid
  );
$$;

-- security definer reads past RLS, so hand it out deliberately.
revoke execute on function public.session_is_live() from public;
grant execute on function public.session_is_live() to authenticated;

Call that from the policies on your sensitive tables, or its equivalent from a route handler, and a signed-out session stops working immediately instead of an hour later. You have deliberately traded the performance property for the revocation property, on the handful of endpoints where revocation is worth more.

Do not extend it to everything: a session lookup on every request is a session system with extra cryptography, and if that is what you want, use sessions. The skill here is knowing which of your endpoints are in which category, which is the same judgment that broken access control rewards.

The small print that bites

Clock skew. RFC 7519 says implementers “MAY provide for some small leeway, usually no more than a few minutes, to account for clock skew.” Take the RFC’s framing literally: a few minutes, configured explicitly. Multi-hour leeway to “fix” intermittent expiry errors is a lifetime extension in disguise, and the underlying problem is almost always an unsynchronised clock on one machine.

iat is not exp. Some code computes expiry as iat + ttl at verification time rather than reading exp. That silently overrides whatever lifetime the issuer chose, it drifts by however wrong the issuing machine’s clock was, and no library validates iat as a lifetime because it was never meant to be one. Read the claim that means expiry.

Expiry does not replace an allowlist. A short-lived token forged through alg=none or signed with a cracked secret has whatever exp the attacker felt like typing. These controls compose in one direction only: verification makes expiry meaningful, and expiry does nothing for verification.

The check

Take a token from your own application and read its payload. Everything in it is public to whoever holds it, so this needs no key, only a decoder that understands base64url:

# The payload is base64url, not base64, so let something decode it properly.
# Piping it to `base64 -d` will mangle or reject it over the padding.
node -e 'const p = process.argv[1].split(".")[1];
  console.log(JSON.parse(Buffer.from(p, "base64url").toString()));' "$TOKEN"

Look for exp. If it is missing, you have found the finding. If it is present, convert it and ask how far away it is, then sign out of the application in another tab and replay the same token against a real endpoint. If the request succeeds, you now know the true length of your revocation window, and it is the number you just measured rather than the one in your auth settings.

This is also something we check rather than only recommend, and it has to happen against a deployed app rather than a repository. Our runtime engine decodes every JWT it finds in the page and its scripts, verifying none of them, and grades the two failures from the top of this post: jwt-no-expiry at medium when a token carries no usable exp at all, and jwt-long-lived at low when one still has more than a year to run, with the evidence line saying how many years.

The gap between those two severities is a deliberate product judgment rather than an oversight. A missing expiry is unbounded and a long one is merely bad, so grading them the same would mean either crying wolf over a thirteen month token or shrugging at a token that never dies. It is the same reasoning that puts exp in this post’s title and revocation in most of its body.

That gap between what the UI implies and what the token permits is the whole subject. It is also a good example of why the running application is its own layer of a release gate: no amount of reading the code tells you what an already-issued credential will still do.

ShareXLinkedIn