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.