OAuth tokens and JWTs keep working after you fire a contractor or a user resets a hijacked account. Here is what to fix in your stack this week.
You boot a contractor from your Supabase project at 2:47pm. You set their organization_role to null in your members table, rotate their API key, send the goodbye Slack message. By 3:15 they have pulled your full customer list through /api/export. Their JWT was issued at 2:30 with org_id baked into the claims, valid for one hour. Your app trusted the token. The token did not know it had been revoked. This is the OAuth token persistence problem, and the advisories landing this week (GHSA-g72g-r7m4-9x4g and GHSA-7p8g-6c6g-h9w7) make it loud for founders: tokens issued before a security event keep working after it. Password resets, role changes, account suspensions, MFA enrollments, breach response. None of it invalidates a bearer token that is already in someone's hands. Your auth provider knows this. The Cursor-generated /api/admin route you shipped last month probably does not.
02Bearer tokens do not get the memo
The whole point of a stateless JWT is that nobody has to call the database to verify it. That is the feature. It is also the bug. When you flip a column in your users table, that change lives in Postgres. It does not live in the JWT sitting in someone's localStorage, or in the OAuth refresh token cached on their old MacBook, or in the Slack channel where they pasted curl -H 'Authorization: Bearer ey...' three weeks ago. Run this check today: open your /api/admin route and grep for where you read the role. If it comes from req.user.role or the decoded JWT, you have the bug. The fix is one line: after you verify the signature, hit the database for the user's current role. On Supabase that is a select on members where user_id = auth.uid(), inside an RLS policy or in your route handler. Yes, this costs a database round trip per protected request. That is the price of being able to revoke access in under five seconds instead of waiting for a token to expire on its own schedule.
03How this ships in apps Guardian scans
The most common version we see: a Bolt or Lovable scaffold generates /api/admin/users/export that reads role from the decoded JWT and never touches the database. The founder demos it, it works, it ships. Six months later they fire someone who knew that route existed. That person has one full hour of valid token. If the JWT TTL is set to 24 hours, which is the default in most starter templates, they have 24 hours. The OAuth refresh case is worse. When a user signs in with Google through Supabase Auth or Clerk, you get a refresh token good for months. If their Google account gets phished and they reset the Google password, that refresh token still works against your app. Google did not invalidate the downstream grants. Your auth provider did not fire a webhook to you. You did not fire a webhook to yourself. The attacker keeps posting to /auth/refresh, getting a fresh access_token, hitting /api/me, pulling the victim's data. The user thinks they fixed their account two weeks ago. They are not fixed.
04What to actually change this week
Three changes, in order of bang per buck. One: shorten your access token TTL to 15 minutes in your Supabase or Clerk dashboard. Long-lived JWTs are the entire reason this bug has teeth. A fired contractor with 15 minutes of valid token has a blast radius you can live with. Two: on every route under /api/admin, anything that touches Stripe, anything that exports data, do a database read for current role and current org membership. Treat the JWT as identity only, never as authorization. Three: build a revoked_sessions table. When you boot a user, insert their user_id with a timestamp. Check that table in your auth middleware. Cache it in Redis with a 30 second TTL so you are not adding latency to every request. You get near-instant revocation instead of waiting an hour for the token to die on its own. Guardian's scanner walks every authenticated route in your app, sends requests with stale tokens, fired-user tokens, and demoted-role tokens, then reports every route that still returns 200. If your /api/admin trusts the JWT and skips the database, we find it before someone with intent does.
Find the /api/admin routes that still trust stale JWTs
Guardian replays requests against your app with revoked, demoted, and expired tokens and flags every authenticated route that still returns 200.
Scan my app free