The symptom
Logged-in people were being logged out without doing anything. A user wrote to support within minutes of it happening to them, and it then reproduced on its own in QA during a test session. The session layer had just shipped strict refresh-token rotation (a refresh token can be used once) and a change that cleared auth cookies on any 401.
What the logs said
I pulled 24 hours of gateway logs from Loki with LogQL instead of guessing from the code. In those 24 hours there were 7,262 refresh attempts and 160 rejections with the "token does not match" signature, spread over about 46 sessions a day. The rejections clustered within milliseconds of each other for the same session: two or three refreshes racing, the first one winning, the rest being rejected as reuse and killing the session.
The cause
The Next.js server components called the API through a server-side client that refreshed tokens on its own. Several components rendering in parallel for one request each noticed the access token was close to expiry and each fired a refresh. With strict rotation, only one could win; the losers got a 401, and the new 401 handler cleared the cookies for everyone. Three correct changes, one bad interaction.
The fix, and what I pushed back on
The team's first fix lived in the gateway: a lock per session, a short grace period where the previous token is still accepted, and reuse detection that revokes the whole token family when a reused token is seen. I reviewed it and raised 5 objections, the main one being that family revocation would punish exactly the concurrent-but-honest clients that caused the race. That objection showed up in production: the first rollout revoked about 12% of active authenticated sessions. I shipped the minimal fix on the frontend side instead: the server-side client no longer refreshes at all (78 lines removed, 1 test added), leaving refresh to the single browser client that already serialized it. It went out as a patch release.
Result
With the gateway fix in production, "does not match" rejections went from 1,274 events over two days to 4 in 24 hours. My frontend fix removed the source of the concurrent refreshes, so the family revocation no longer had honest clients to punish, and the team later widened the grace period. The write-up and the LogQL queries are in the internal wiki so the next person doesn't start from zero.
What I'd tell another team
- Measure the blast radius before and after. One query a day is cheap; a wrong revocation policy is not.
- Strict rotation needs a grace period and idempotent retries, or every client race becomes an outage.
- The smallest fix is usually on the side that shouldn't have been doing the work in the first place.