The first line of a JWT security checklist is a test you can run today: change the password, replay the old token, and see whether it still gets a 200. A signed token stays valid until its exp claim passes, so logout and password change revoke nothing unless the server keeps something to check. Five controls close that gap, and each has a test.
JWT security: the five controls, and what each one stops
JWT security for a live app is 5 controls: a short access-token expiry, refresh token rotation with reuse detection, revocation on logout and password change, a strong signing key with a pinned algorithm, and a cookie that script cannot read. Each one has a test you can fail.
These five are the session half of the authentication checklist for an AI-built app; sign-up, roles and the rest of the account sit there, and this page stays on how a token expires, rotates and dies.
My reading of whether a JSON Web Token is secure: the format is rarely the weak part. RFC 8725, the JWT best current practices, was written after “several widely published attacks on implementations and deployments”, which it traces to “under-specified security mechanisms, as well as incomplete implementations and incorrect usage by applications”. Its remedies are about what the code around the token does: a pinned algorithm list, keys with real entropy, and issuer and audience checks.
Securing JWT sessions on a live app is therefore lifecycle work, and the table below is the session and token security checklist I’d run on any app that signs its own tokens or takes them from Supabase, Firebase or Clerk. For JWT authentication, best practices only count when you can test them, so each row names the control, what it stops, where it is set and the test that proves it. The same best practices hold for any authentication based on a token the client carries, whether it is a signed JWT or an opaque session id.
| Control | What it stops | Where it is set | The test |
|---|---|---|---|
| 1. Short access-token expiry | A stolen token living for days | Expiry, below | The expiry test |
| 2. Refresh token rotation with reuse detection | A stolen refresh token being used quietly | Refresh token rotation, below | The refresh reuse test |
| 3. Revocation on logout and password change | The old session outliving the owner’s attempt to secure the account | Revoking sessions, below | The logout and password-change tests |
| 4. A strong signing key and a pinned algorithm | Forged tokens | Signing keys and algorithms | The tampering test |
| 5. The cookie or storage that carries the token | Theft by script, and session fixation | Where to store the token; the auth cookie | The DevTools cookie check before the tests |
A JWT is a token format, while OAuth 2.0 and OpenID Connect are protocols that often carry one; choosing between sign-in protocols is the OpenID Connect vs SAML question, and nothing on this page depends on it.
How JWT tokens work: header, payload, signature
JWT stands for JSON Web Token, the format RFC 7519 defines as “a compact, URL-safe means of representing claims to be transferred between two parties”. A signed JWT is three base64url-encoded parts joined by dots: header, payload and signature.
The JWT header names the signing algorithm in alg and often the key in kid, which the JWS spec calls “a hint indicating which key was used to secure the JWS”. The JWT payload holds the claims: sub for the user, iss for who issued it, aud for who it is meant for, iat for when it was issued, exp for the time “on or after which the JWT MUST NOT be accepted for processing”, and jti, a unique id that “can be used to prevent the JWT from being replayed”. The signature is computed over the encoded header and payload, so changing one character of either breaks it.
In security terms a JWT is a bearer credential: whoever holds it is treated as the user until it expires. Base64url is an encoding, not encryption, so anyone holding the token can read the payload; it should carry ids, never personal data or secrets. The server verifies the signature and the claims on every request and trusts nothing it has not verified.
What goes wrong without it
| Symptom | Cause | The control that fixes it |
|---|---|---|
| Old session still works after password change | The password changed in the database, but the token already issued is still signed and unexpired | 3, revocation |
| A review finds the JWT never expires | No exp claim, or an expiry of months set so users are never logged out | 1, expiry |
| Logout looks fine, but a copy of the token still gets answers | Logout only deleted the token from the browser | 3, revocation |
| A stolen refresh token keeps minting access tokens | No rotation, no reuse detection, no end to the session | 2, rotation |
alg: none accepted, RS256 swapped for HS256, or a guessable shared secret | A verifier that trusts the token’s header, or a weak key | 4, signing key and pinned algorithm |
Every JWT attack in the last row needs a mistake on the server’s side. RFC 8725 records that an attacker can change the algorithm to none and “some libraries would trust this value”, and that an RS256 value can be changed to HS256 so that some libraries validate the signature “using the RSA public key as the HMAC shared secret”. A short secret is the third case: such keys “are vulnerable to offline brute-force or dictionary attacks once an attacker gets hold of such a token”. None of these JWT attacks works against a verifier that pins its algorithm and holds a random key.
One public advisory shows the first and third rows together. In February 2026 the open-source project initiative (Morelitea/initiative on GitHub) published advisory GHSA-hww6-3fww-xw3h, rated High: the application “does not invalidate previously issued JWT access tokens after a user changes their password”, so “older tokens remain valid until expiration”. The same report notes that “Tokens are not invalidated upon logout” and that “No observable revocation mechanism (e.g., blacklist, jti tracking, token versioning) is enforced”; versions before 0.32.4 were affected and 0.32.4 is the patched release. The lesson I take from it: logout and password change end a session only when the server keeps something it checks on every request, and signing the token was never the missing part.
The audit numbers point the same way. The Authentication pillar averages 52.8 out of 100 across 14 of the 21 third-party apps I audited, the ones it was scored on. They are public and held-out apps from June and July 2026, a selected set of 21 picked for review rather than a random sample, so the figure describes those apps and is not a rate for AI-built apps in general. In the same audits, 11 of the 21 third-party apps had unauthenticated endpoints doing privileged work, the neighboring failure where no token is checked at all.
A valid token proves who is calling, not which records they may read. That gap is broken access control, and one API form of it is IDOR, where one changed id exposes another customer’s data. The same token cases, tried from the API side, are in the API security checklist for AI-built apps.
How to do it: expiry, refresh rotation, revocation
Session and token controls are built in 3 moves: set a short expiry on the access token, rotate the refresh token on every use, and give the server one thing to check so logout and password change end the session.
Every provider behavior below is taken from that provider’s own docs, read on October 3, 2026.
Expiry: a JWT that never expires is a finding
A JWT with no exp claim, or an expiry of months, is a finding: a stolen copy keeps working until someone rotates the signing key. My working rule is minutes for an access token, never days, and the session above it needs an idle timeout and an absolute limit.
A JWT that never expires is a security issue the spec allows: RFC 7519 lists exp as optional, so a token issued without one has no expiry time at all. The upper end of my working rule is about an hour.
The managed providers sit inside that rule. Supabase says “Most applications should use the default expiration time of 1 hour” and that “Values below 5 minutes, and especially below 2 minutes, should not be used in most situations”. Firebase ID tokens “are short lived and last for an hour”. Clerk sets “an extremely short session token lifetime of 60 seconds” and refreshes it from the browser on a 50-second interval.
On your own server the library sets exp only when asked. The jsonwebtoken README says “There are no default values for expiresIn”, and its verify call treats expiration as “optional”, checking exp only when the token has one. In jose, jwtVerify takes a requiredClaims list, so requiredClaims: ['exp'] makes a token without exp fail.
The session above the token needs limits of its own, or a refresh token keeps it alive with no end. OWASP’s Session Management Cheat Sheet says “All sessions should implement an idle or inactivity timeout” and “All sessions should implement an absolute timeout, regardless of session activity”. On Supabase those are the Time-box user sessions and Inactivity timeout settings, available “on Pro Plans and up”. On Clerk, Inactivity timeout is off by default and Maximum lifetime defaults to 7 days on newly created instances; turning on Inactivity timeout or setting a custom Maximum lifetime requires a paid plan for production use.
What is refresh token rotation, and what reuse detection does
Refresh token rotation issues a new refresh token on every use and retires the old one. Reuse detection is the second half: when a retired token comes back, two parties hold the session, so the server revokes the live refresh token and the user has to sign in again.
RFC 9700, the OAuth security best current practice, describes the mechanism: “If a refresh token is compromised and subsequently used by both the attacker and the legitimate client, one of them will present an invalidated refresh token”. The server “cannot determine which party submitted the invalid refresh token, but it will revoke the active refresh token”, which “stops the attack at the cost of forcing the legitimate client to obtain a fresh authorization grant”. The legitimate user is the one who gets signed out, and that is the point; the UI should say why, so they do not read it as a bug.
Supabase rotates on every refresh, with two documented allowances for flaky networks. A refresh token “can be used more than once within a defined reuse interval. By default this is 10 seconds”, and if the parent of the current token comes back, “the active token will be returned”. Outside those two cases “the whole session is regarded as terminated and all refresh tokens belonging to it are marked as revoked”. Firebase’s docs do not promise rotation: its REST reference says a refresh returns “the Firebase Auth refresh token provided in the request or a new refresh token”, and its session page says refresh tokens “expire only when” the user is deleted or disabled or “a major account change is detected”.
On your own server my working rule is to store each refresh token as a hash, tied to a session id, and to keep the retired ones long enough to recognize them. Rotation without reuse detection is only half the control: it shortens how long a stolen token is useful, but nothing notices when it is used.
How to revoke user sessions on logout and on password change
Revoking a JWT means giving the server something to check, because the signature alone stays valid until exp. There are 4 designs: short expiry with a revocable refresh token, a session id checked on each request, a per-user token version, or a denylist of token ids.
| Design | How it works | Cost (my reading) | When to use |
|---|---|---|---|
| (a) Short expiry plus a revocable refresh token | Logout and password change kill the refresh token; the access token dies within its lifetime | No lookup per request; a window equal to the access-token lifetime | The base for every app |
| (b) Session id claim checked on every request | The token carries a session id; the server checks it against a session table | One lookup per request | Sensitive routes, or when the window in (a) is too long |
| (c) Per-user token version or “not valid before” time | Password change bumps the version; tokens issued before it are refused | One lookup per request | Password change, reset and sign-out everywhere |
(d) Denylist of token ids (jti) | Each revoked jti sits in a fast store such as Redis until its exp passes | One lookup and a store to run | Killing one known token early |
For a small app my working rule is (a) plus (c): it keeps requests cheap and still ends every session when the password changes. The events that must revoke are logout, password change, password reset, email change, role change, account deletion and a confirmed theft. Two providers document one of these designs directly: Supabase puts a session_id claim in every access token and says you “can check that the session_id claim in the JWT corresponds to a row in the auth.sessions table” (design b), and Firebase’s revokeRefreshTokens records the revocation time as the user’s tokensValidAfterTime, while verifyIdToken with checkRevoked refuses a token revoked that way (design c).
| Provider | What logout does | What password change does | The setting |
|---|---|---|---|
| Supabase | signOut() scope global (the JavaScript default) ends all the user’s sessions; local ends this one; others ends the rest | A session terminates, “depending on configuration”, when the user changes their password | The scope option; Time-box and Inactivity timeout in Auth settings (Pro plans and up) |
| Firebase | Not stated in Firebase’s session docs for a client sign-out; the Admin SDK’s revokeRefreshTokens(uid) revokes the user’s refresh tokens | Refresh tokens expire on “password or email address updates”; “Password resets also revoke a user’s existing tokens” | verifyIdToken(idToken, checkRevoked) with checkRevoked true |
| Clerk | Backend revokeSession(): “The user will be signed out from the client the session is associated with” | updatePassword() takes signOutOfOtherSessions to sign the user out of all active sessions | Inactivity timeout and Maximum lifetime, Clerk’s session options (custom values need a paid plan in production) |
| Your own server | Design (a) plus (b) or (d) | Design (c) | Your session table and verify middleware |
None of the three recalls an access token it already issued. Supabase’s sign-out docs say access tokens of revoked sessions “remain valid until their expiry time, encoded in the exp claim”, and Supabase’s guide to user sessions gives the session_id check above as the way to refuse one after sign-out. Firebase’s guide to managing user sessions says a revoked ID token can be detected “only by requesting the token’s status from the Firebase Authentication backend”. Clerk’s overview of how its sessions work states that “JWTs cannot be revoked due to their self-contained nature”, which is why its session token lives 60 seconds. That window is why expiry is control 1.
The user-facing half, a device list where people end a session themselves, is the question of what is active session management. The flows that fire these events (sign-up, login, reset) follow authentication best practices, and an action risky enough to ask again for a second factor follows how to implement two-factor authentication on owner and admin accounts.
Where to store JWT tokens in the browser
The safest place to store a JWT in a web app is a cookie set by the server with 3 attributes: HttpOnly, Secure and SameSite. Script cannot read it, so an XSS bug cannot copy the token out of the page. It then needs a CSRF defense.
The trade-off runs both ways. localStorage is readable by any script in the origin; OWASP puts it plainly: “a single XSS vulnerability discloses every token”. That bug class is covered in how to mitigate cross-site scripting. An HttpOnly cookie cannot be read by script, but the browser still sends it with requests the page’s scripts make, so it needs SameSite and a CSRF token, which OWASP says SameSite does not replace. The defense itself is CSRF mitigation.
For a web app with its own backend I recommend the HttpOnly cookie. Provider libraries differ here. Supabase’s server-side setup stores “the user session in cookies instead of local storage” through the @supabase/ssr package, which its docs call beta, and Supabase says HTTP-only cookies work only for apps where all logic runs on the server and it returns rendered HTML. Clerk’s __session cookie is not HttpOnly by design and lives 60 seconds to limit what a stolen copy is worth. Native apps keep tokens in the platform’s secure keystore, not in plain app storage.
Signing keys and algorithms: the JWT secret key, HS256 and RS256
JWT keys come in two kinds. A JWT secret key for HS256 is random and at least 256 bits, lives only on the server, and both signs and verifies. RS256 signs with a private key and verifies with a public one, so use it when more than one service checks tokens.
RFC 7518 lists the JWT signing algorithms; three matter for a small app, plus none, which a login token must never carry. With HS256, a JWT shared secret both signs and verifies, so every service that can verify a token can also forge one; with RS256 or ES256 a verifier holds only the public key and cannot. My working rule for the last column: HS256 when one backend both issues and verifies, an asymmetric algorithm when more than one service verifies or a third party does.
| Algorithm | What RFC 7518 calls it, and the key | Who can verify | Use when |
|---|---|---|---|
| HS256 | HMAC using SHA-256; one shared secret of 256 bits or more | Anyone holding the secret, who can also sign | One backend issues and verifies |
| RS256 | RSASSA-PKCS1-v1_5 using SHA-256; an RSA key pair of 2048 bits or more | Anyone with the public key; only the private key signs | Several services or a partner verify |
| ES256 | ECDSA using P-256 and SHA-256; an elliptic-curve key pair | Anyone with the public key; only the private key signs | As RS256; Supabase recommends P-256 over RSA |
| none | No digital signature or MAC performed | Nobody, since nothing is signed | Never on a login token |
RFC 7518 requires a key “of the same size as the hash output (for instance, 256 bits for “HS256”) or larger”. Generate it with a cryptographic random generator, never a word or a template’s placeholder; RFC 8725 says “human-memorizable passwords MUST NOT be directly used as the key”. On any machine with Node, node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))" prints a new secret of 32 random bytes, which is 256 bits. A JWT cracker only guesses a short secret offline from one captured token, so the defense is the key’s length, not keeping the algorithm quiet.
Whoever verifies should decide which algorithm may sign a JWT, never the token’s header. Pin the allowed JWT algorithms in code, as RFC 8725 requires of libraries: let the caller “specify a supported set of algorithms” and “not use any other algorithms”. In the jsonwebtoken library the pin is the algorithms option of jwt.verify; left out, the README says defaults are used “based on the type of key provided”, and for RS256 the verifier is given only the PEM public key. The jose library has the same algorithms option on jwtVerify, and its docs say unsecured tokens with alg: "none" “are never accepted”.
The secret lives in a server-side environment variable or a secrets manager, never in the client bundle or the repository; the wider rules are secrets management best practices. Supabase warns that a shared secret can easily leak into public code “such as in your website or frontend”, especially when it sits in environment variables prefixed with NEXT_PUBLIC_, VITE_ or PUBLIC_. Rotating a shared secret signs everyone out unless the verifier accepts the old and the new key, picked by kid, for one token lifetime; the step-by-step is how to rotate API keys safely. On Supabase the legacy JWT secret and the newer signing keys are project settings, and rotating the legacy secret means “Currently active users get immediately signed out”, while the signing keys system says “No users get signed out”. A move to a new Supabase project with a different secret logs every user out too, which is why logins stop working after a migration.
What is an RS256 JWT token?
An RS256 JWT is signed with an RSA private key over a SHA-256 hash, and anyone holding the matching public key can verify it. It is RSA used as a signature, not encryption, and it fits when a second service or a partner must check the token.
RFC 7518 names RS256 “RSASSA-PKCS1-v1_5 using SHA-256” and requires “A key of size 2048 bits or larger”. My reading on whether it is secure: yes at that key size, when the verifier pins RS256 and checks exp, iss and aud. Firebase ID tokens, for example, carry alg “RS256” and are checked against Google’s published keys. For jsonwebtoken RS256 verification the README’s own example passes the public key and { algorithms: ['RS256'] }, and a token with any other alg fails as “invalid signature”. The choice between RS256 and HS256 is the last column of the table above.
Encrypted JWT: signed is not secret
An encrypted JWT is a JWE. The signed tokens (JWS) that Supabase, Firebase and Clerk issue hide nothing: signing proves who issued the payload, and anyone holding the token can read it. Keep personal data out of a signed token’s payload instead.
RFC 7516 defines JWE, which “utilizes authenticated encryption to ensure the confidentiality and integrity of the plaintext”; a compact JWE has “five segments separated by four period (’.’) characters”, where a signed token has three. Supabase’s documented example payload includes email and phone claims, which anyone holding that token can read. My reading: a small app almost never needs a JWT encrypted end to end. Keep personal data out of the payload and send the token over HTTPS; if a claim must be hidden from the client, use an opaque session id and keep the data on the server.
The auth cookie: flags, session fixation and cookie tossing
Authentication cookies are bearer credentials, so 4 settings decide who can read and send them: HttpOnly, Secure, SameSite, and the __Host- name prefix, which forbids a Domain. A new session id at login stops fixation, and the prefix stops cookie tossing.
In cyber security terms, cookies that hold a session are credentials in their own right. The attributes below come from MDN’s Set-Cookie reference.
| Attribute | Value for an auth cookie | What it stops |
|---|---|---|
HttpOnly | Set | Script reading the cookie through document.cookie; it mitigates XSS token theft |
Secure | Set | Sending the cookie over plain http, which leaves it open to a manipulator in the middle |
SameSite | Lax or Strict | Sending the cookie on cross-site requests; some protection against CSRF |
Path | / | Nothing by itself (MDN: not a security measure); the __Host- prefix requires it |
Domain | Left out | Sharing with subdomains; without it the cookie is host-only |
Max-Age | Your session policy, or your provider’s advice | A cookie the browser keeps longer than you intended |
__Host- name prefix | Name starts with __Host- | Any other host on the domain setting or receiving it; requires Secure, no Domain, Path=/ |
One provider note on Max-Age: Supabase asks for its token cookies to be set “very far into the future” so that Supabase Auth, not the browser, decides when the session ends.
Session fixation is the attack where the app keeps the pre-login session id after login, so an id planted before login becomes an authenticated one. OWASP’s session fixation page describes an app that, “When authenticating a user, it doesn’t assign a new session ID”. The fix is a new session id at login and at every privilege change; the cheat sheet names “password changes, permission changes, or switching from a regular user role to an administrator role”.
In the 2025 edition of the OWASP Top 10, a session fixation vulnerability falls under A07:2025 Authentication Failures, which lists CWE-384 Session Fixation among its notable weaknesses and includes an app that “Reuses the same session identifier after successful login”. The same OWASP Top 10 category covers session management failures too: an app that does not correctly invalidate user sessions or authentication tokens, “mainly single sign-on (SSO) tokens”, “during logout or a period of inactivity”. For session management best practices in detail, OWASP’s own source is the Session Management Cheat Sheet linked above.
Cookie tossing works from a sibling host. A user-content or preview subdomain sets a cookie for the parent domain, which MDN shows is allowed (“a response from api.example.com can set Domain=api.example.com or Domain=example.com”), and the app then reads that cookie as its own. A __Host- cookie “must not have a Domain attribute specified”, so no sibling can set one in its place. My rule on top: user content belongs on a separate registrable domain, in line with OWASP’s advice “not to mix web applications of different security levels on the same domain”. Consent banners are a separate topic and are not covered here.
Reading a token safely: JWT tools, jwt.io, and how to verify a JWT signature with a public key
A JWT tool decodes a token without any key, because the header and payload are only base64url. Verifying is different: it needs the secret or the issuer’s public key, a pinned algorithm, and 3 claim checks: exp, iss and aud. Never paste a live token.
You cannot verify a JWT without a key; what works without the secret is decoding, and decoding proves nothing about who issued the token. The jsonwebtoken README says the same of its decode call, which “Returns the decoded payload without verifying if the signature is valid”.
Whether jwt.io is safe to use comes down to what you paste into it. Its debugger invites you to paste a token “to decode, validate, and verify”, and its encoder says a private key “never leaves your browser”; the page states nothing about where a pasted token is decoded. The rule holds for every online JWT tool: never paste a live production token or a signing secret into a website, because a valid token is a credential until it expires. Use an expired token, a staging token, or decode it locally:
// node decode.js "<token>" reads the header and payload, verifies nothing
const [h, p] = process.argv[2].split('.');
const dec = (s) => JSON.parse(Buffer.from(s, 'base64url').toString());
console.log(dec(h), dec(p));
Here is how to verify a JWT token with a public key, in five steps:
- 01 Read
kidandalgfrom the token header. - 02 Fetch the issuer's key set from the
jwks_uriin its/.well-known/openid-configurationdocument, or from the key URL the provider publishes. - 03 Pick the public key whose
kidmatches the header. - 04 Verify the signature with a library, passing a pinned algorithm list.
- 05 Check
exp,issandaud, and refuse the token if any check fails.
The well-known path and jwks_uri come from OpenID Connect Discovery, which says the document sits at “/.well-known/openid-configuration” appended to the issuer. Supabase publishes its public keys at https://<project-id>.supabase.co/auth/v1/.well-known/jwks.json when the project uses asymmetric signing keys (the endpoint returns none for a shared secret) and caches that endpoint “for 10 minutes” at its edge. Firebase lists its keys at https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com. In Node with jose, steps two to five are one call for an issuer that publishes a JWKS URL, such as Supabase:
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL(JWKS_URL));
const { payload } = await jwtVerify(token, JWKS, {
algorithms: ['RS256'], // the one algorithm your issuer uses
issuer: ISSUER,
audience: AUDIENCE,
requiredClaims: ['exp'],
});
The public key can only verify, never sign, so it is safe to publish; Supabase’s docs say public keys “can only be used to verify the signature of JSON Web Tokens, but not create new ones”. JWT testing on an app you own is the five tests below, not a scanner.
How to verify it
Session and token controls are verified with 5 replay tests on your own app: an expired token, a token after logout, a token after a password change, a retired refresh token, and a tampered token. Each must be refused, with the request and response kept.
Two browsers and a request tool are enough. Start in the browser: open DevTools, read the auth cookie’s flags in the Application tab, then in the Network tab use Copy as cURL on a signed-in request, log out, and replay it from a terminal.
A refusal is a 401 from an API route; Supabase’s Data API also returns 401 for an expired or tampered token, and a row level security policy that checks the session id returns no rows for that user’s data instead. Record which one each route gives, since a correct build may give either. These five are how to test token based authentication from the outside, without reading the code; the first is the plain check that an expired session is rejected:
- 01 Expiry: copy an access token, wait past its lifetime, replay one protected request, and expect a refusal. Keep the request, the response and both timestamps.
- 02 Logout: copy the access and refresh tokens, log out, replay the refresh call and expect a refusal. Then replay the access token and record how long it keeps working, which should be no longer than its expiry.
- 03 Password change: sign in on two browsers, change the password in one, and replay from the other. Expect the refresh refused, and the access token refused within its lifetime.
- 04 Refresh reuse, where the stack rotates refresh tokens: refresh twice, wait past the provider's reuse window, present the first refresh token, and expect a refusal with the session revoked.
- 05 Tampering: change one character of the payload and resend, then send a token whose header names the none algorithm, built on your own machine for your own app. Expect a refusal both times.
Test 4 refreshes twice on Supabase because its docs return the active token when the parent of the current one comes back, and allow reuse within a 10-second window by default. Where a provider’s docs do not say it rotates refresh tokens, as Firebase’s session page does not, record that line instead of a pass. Test 3 on Firebase should show the refresh refused at once, since a password update expires refresh tokens, while the ID token keeps working until its hour is up unless the server verifies with checkRevoked.
Evidence goes in one dated file: each request, the status code or row count it returned, and the time. Fail any one test and the matching control in the first table is the fix.
In the Production Hardening Sprint, deliverable 1.2, Session and token controls, is verified this way: confirm expired and revoked sessions cannot continue accessing protected resources.
Where the sprint does this
Deliverable 1.2 is where we verify expiry, refresh, and session revocation on logout and password change, because an old session can remain usable after an account owner tries to secure it. Deliverable 1.9 provides session listing and a way to sign out across all active sessions. The production readiness report, deliverable 13.1, accounts for all 123 IDs, keeps failures visible until resolved and explains genuine non-applicable items. Your app’s current framework and hosting setup are our starting point, and we refactor or replace components where the production work requires it. Formal third-party certifications and independent audit opinions are separate from these engineering deliverables. Each item, with how it is verified, is listed in the 123 deliverables in the published scope.
Common questions about JWTs and sessions
Which is better, JWT or session?
For an app with one backend, a server session is usually the simpler and safer default, because it can be revoked at once: delete the row and the session ends. A JWT earns its place when several services verify the same token without calling a central store, and then it needs one of the revocation designs above. That is my reading; Supabase makes the opposite trade for its own stack, preferring JWTs because with server sessions, if the auth server “is unavailable for even a few seconds, the whole application goes down”.
What if someone steals my JWT token?
A stolen JWT keeps working until its exp passes or until a server-side check refuses it. End the session with a session-id check or a token-version bump, sign the user out everywhere so the refresh tokens die, and rotate the signing key only if the key itself leaked, since on a legacy Supabase secret that rotation signs every user out.
Can JWT be compromised?
Yes, in four ways, each matching a control in the first table: a weak or leaked signing secret, a verifier that trusts the algorithm in the token’s header, a token copied out of script-readable storage, and a token that stays valid after logout or a password change. The first two are the RFC 8725 cases; the last two are controls 5 and 3.
Is JWT different than OAuth?
Yes. A JWT is a token format, and OAuth 2.0 is a protocol for granting access that often uses JWTs as its access and refresh tokens, as does OpenID Connect for its ID tokens. Which sign-in protocol to adopt, OpenID Connect or SAML, is a separate decision from how the token is stored and revoked.
Are JWT tokens still used?
Yes. Supabase access tokens, Firebase ID tokens and Clerk session tokens are all JWTs, by each provider’s own docs.
Does JWT use SHA-256?
Yes, in HS256 and RS256: RFC 7518 defines HS256 as “HMAC using SHA-256” and RS256 as “RSASSA-PKCS1-v1_5 using SHA-256”. SHA-256 is the hash inside the signature; the token itself is not a hash, and the payload stays readable.
What are the disadvantages of JWT?
Four: no built-in revocation, a payload anyone holding the token can read, extra bytes on every request, and signing keys to manage and rotate. Clerk’s docs name the first directly, “JWTs cannot be revoked due to their self-contained nature”, and Supabase notes that P-256 signatures are “significantly shorter than those created by RSA”, which “helps in managing cookie size”.
The checks in this guide show you where the app is open. The sprint below closes those gaps, tests the result and writes the evidence down.
Built it with AI. Now it has to hold up for real customers.
The Production Hardening Sprint takes the app you already have and builds the production foundation underneath it. Authentication and access rules, payments that stay consistent, error handling, monitoring, backups, automated tests and a documented handover. Our engineers work inside your existing codebase for ten working days. All 123 deliverables are included, and you get the evidence for each one.
See the Production Hardening Sprint →
$2,500 fixed price · 10 working days · One codebase