Maintenance, Support & Optimisation
Rotating a JWT secret without logging users out
How we rotated Easinvy Retail's JWT secret across two hosts in November 2026: the order that avoids a login loop, why 17 hours, and why a leak is different.
Ridam Agrawal
- 7 min
On this page
How do you rotate a JWT secret without logging users out?
Add the new secret wherever tokens are only checked, then switch the signer to it and keep the old secret as verify-only. Each token signed with the old secret is re-signed on its next request. Drop the old secret once every old token has expired: 17 hours on Easinvy Retail. A leaked secret gets no grace period.
Easinvy Retail, our billing and inventory product for shops, signs its session tokens with one shared secret (HS256). On 12 and 13 November 2026 we rotated that secret across the two hosts that check it, and a shop session we kept open stayed signed in through every deploy. The mechanism has been in the code since 14 September 2026. A first rotation started that day stopped halfway, and that half-finished state is part of this note. Every setting, step and time below comes from Easinvy Retail's code, its hosting settings and the moments each deploy went live.
Two places check an Easinvy Retail session token
Easinvy Retail's backend, on Railway, is the only part that signs a session token. The frontend, on Vercel, never signs, but its proxy checks the token on every request to a shop's pages before it asks the backend anything. When the signature does not verify, the proxy deletes the session cookie and sends the browser to the login page. Two hosts therefore hold the secret, and each needs its own deploy, so they can never change at the same instant.
Each host reads two settings. JWT_SECRET is the current secret.
JWT_SECRET_PREVIOUS holds secrets being retired, comma-separated, and they only
ever verify. The backend tries JWT_SECRET first and then each retired secret.
The frontend accepts any of them equally, because a host that never signs has no
reason to prefer one.
The three steps, in order
| Step | Frontend (verifies) | Backend (signs) |
|---|---|---|
| 1 | JWT_SECRET_PREVIOUS = the new secret | unchanged, still signing with the old one |
| 2 | unchanged | JWT_SECRET = the new secret, JWT_SECRET_PREVIOUS = the old one |
| 3, one token lifetime later | JWT_SECRET = the new secret, no JWT_SECRET_PREVIOUS | no JWT_SECRET_PREVIOUS |
Each step is a settings change followed by a redeploy of that host. The order follows one rule: no host signs with a secret that another host cannot yet verify. After step 1 the frontend accepts both secrets and nothing else has changed. After step 2 every new session is signed with the new secret. After step 3 the old secret verifies nowhere.
What reversing steps 1 and 2 does: a login loop
Switching the backend first creates a login loop. The backend signs with a secret the frontend's proxy does not know yet. The proxy refuses the token, deletes the cookie and sends the user to the login page. Signing in again issues another token the proxy cannot verify, and the user lands on the login page again. The loop lasts until the frontend's deploy finishes, and it catches every active user, not only new sign-ins, because the backend also re-signs live sessions with the new secret as they are used.
Re-signed on the next request, not at expiry
Easinvy Retail does not wait for old tokens to run out. When a token verifies only against a retired secret, the backend marks it for refresh, and the same response carries a token signed with the current secret back in the session cookie. A token that is otherwise fine is re-issued only once it is an hour old; one signed with a retired secret is re-issued at once, however new it is. The frontend's proxy passes a refreshed cookie from its own session check on to the browser, so opening a page counts as use. A session in use moves to the new secret on its next request.
Why the old secret can go after 17 hours
Every Easinvy Retail token expires at the sooner of two times: 16 hours after it was issued, and 30 days after the user signed in. Once step 2 is live, the backend issues nothing under the old secret, so the last token it signed with the old one is dead 16 hours later. Step 3 waits one more hour. The 17 hours come from that idle window, so a system with a different token lifetime gets a different number. The cut-off and the 30-day cap are described in How to revoke a JWT, from a system that does it.
The rotation we found half-finished
Easinvy Retail's first rotation shows why step 3 matters most. A rotation started on 14 September 2026 got through steps 1 and 2 and stopped there. When we came back to it on 12 November, the backend was signing with the September secret and still accepted the original one as retired, and the frontend still held the original as its main secret. Nothing looked wrong, because sessions worked: any token still signed with the original was re-signed quietly the next time it was used. The secret the rotation was meant to retire had stayed valid on both hosts for two months.
Starting from that state changed step 1. The frontend's retired-secret setting
was marked sensitive, so its value could not be read back. Step 1 therefore set
the frontend's JWT_SECRET to the backend's current secret, copied from the
backend, and put the new secret in JWT_SECRET_PREVIOUS. The November rotation
retired both older secrets.
The rotation, 12 and 13 November 2026
- Step 1, the frontend: live at about 21:34 IST on 12 November.
- Step 2, the backend: live at about 21:49 IST. Editing the backend's settings started no deploy by itself, so it was redeployed from the host's dashboard.
- Step 3: the backend at about 23:57 IST, the frontend at about 01:35 IST on 13 November.
A shop session we kept open in a browser stayed signed in through all four deploys. We ran step 3 about two hours after step 2 rather than 17, by choice, knowing the cost: a session last used before 21:49 had to sign in once more. Easinvy Retail logs neither re-signed tokens nor refused ones, so we do not know how many sessions that was.
A leaked JWT secret is a different procedure
Everything above is for a routine rotation. When a secret has leaked, keeping it
as verify-only is the mistake. Easinvy Retail accepts any token a retired secret
signed and re-signs it with the new secret on its first request, so a token
forged with the leaked secret would come back with a valid signature. The
backend does re-check the user's membership of the shop on every shop request (how
shops are kept apart is in
Multi-tenancy in Drizzle, from a shipped product),
but anyone holding the secret can name a real user. The per-user sign-out switch
does not help either: it compares a cut-off with the token's own auth_time
claim, and whoever forges the token writes that claim. After a leak, the only
answer is a new secret on both hosts with nothing in JWT_SECRET_PREVIOUS, and
every user signs in again.
Rotating without a kid header
A JWT header can name the key that signed it. RFC 7515 defines kid as
a hint indicating which key was used
and makes it optional. Easinvy Retail does not set it. The backend tries the
current secret and then each retired one, which costs one more HMAC for a token
signed with an old secret, and only until that same request re-signs it. With
one signer and two or three secrets, a key lookup is not worth a header.
OWASP's JWT cheat sheet
notes that public-key signatures make rotation simpler, because a public key can
be published, while a shared secret needs its own way to be distributed and
rotated. The three steps are ours.
The tests behind the mechanism
Easinvy Retail's session test suite, npm run test:sessions, holds 49 checks
run against a local server started with a retired secret configured. Four cover
rotation: a token signed with the retired secret is accepted; it is re-signed at
once, though it is only a minute old; the new token verifies with the current
secret and not the retired one; and a token signed with any other secret is
refused. The suite was written with the mechanism in September 2026. It tests
the code; the November rotation tested the deploys.
A checklist from this rotation
- List every place that verifies the token, not only the one that signs.
- Give every verifier the new secret before the signer uses it.
- Keep the old secret verify-only, and re-sign on use.
- Wait one token lifetime and a margin, then remove the old secret everywhere.
- Finish step 3. An unfinished rotation leaves the old secret valid for as long as nobody looks.
- For a leak, skip all of this: a new secret everywhere, no grace period.
- Generate the secret, never type one. RFC 8725 says human-memorizable passwords must not be used as an HS256 key. Ours is 48 random bytes, 64 characters in base64url.
Easinvy's maintenance and support work includes rotations like this one.
Questions people ask
- When is it safe to remove the old JWT secret?
- When no token signed with it can still be valid. Easinvy Retail's tokens expire 16 hours after they were issued, so 17 hours after the backend stops signing with the old secret, nothing it signed is still alive. Removing it sooner signs out anyone whose session was last used before the switch.
- What happens to logged-in users when the JWT secret changes?
- If the secret is replaced in one step, every existing token fails its signature check and every user is signed out at once. With the old secret kept as verify-only, existing tokens keep working and are re-signed with the new secret the next time they are used.
- What should you do if a JWT secret is leaked?
- Replace the secret everywhere at once with no grace period, and accept that every user signs in again. Keeping a leaked secret as verify-only lets anyone who holds it mint tokens that are accepted and then re-signed with the new secret.
- How often should a JWT secret be rotated?
- No standard sets an interval. RFC 8725, the best current practice for JWTs, does not mention rotation. Once a rotation signs nobody out, the interval is a policy choice, and on Easinvy Retail a rotation costs four deploys and a wait of 17 hours.
Sources
Ridam Agrawal
Founder and engineer at Easinvy. Writes up the parts of building and running a multi-tenant product that were expensive to get wrong. More about the studio →
Related
Taking over a software project: the first month
One takeover read from its commits: a generated frontend kept, a new backend behind it, users moved with their old ids, and what is still missing.
MVP development cost, counted in days
Three MVPs we shipped, timed from the first commit: a store live the next day, an ERP on its server in 35 minutes, a parts store in eight days.
Maintenance, Support & Optimisation
Hosted, deployed, updated and made faster, long after launch.
All notes
Write-ups from building and running our own product.