For months, a handful of people reported the same thing: they were signed out every few days, on a device they never left, with no pattern anyone could pin down. Nothing in the logs looked like an attack. Nothing looked like an expiry either — the sessions were nowhere near their idle or absolute timeouts.
The sessions were being deliberately terminated. By us. As suspected theft.
The check that did it
Every session row stores the User-Agent the session was created with. On each authenticated request, touchSession() compared the stored string with the one on the current request. A mismatch meant the session cookie was being presented by a different client than the one it was issued to — which is a genuinely good signal, because that is what a replayed cookie looks like.
// the old comparison
if (session.user_agent !== userAgent) {
// terminate: this cookie is being used by a different client
}
The problem is what a User-Agent string actually contains:
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)
AppleWebKit/605.1.15 (KHTML, like Gecko)
Version/17.4.1 Safari/605.1.15
Four version numbers. Browsers update themselves, quietly, in the background, and when they do, one or more of those numbers changes. The device is the same. The user is the same. The cookie was never out of their possession. But the string is different, so the comparison fails, and a security control fires on a Tuesday-afternoon Chrome update.
This is the worst shape a security check can take: it produces its false positives on completely ordinary behaviour, at a frequency set by somebody else's release cadence, and its symptom — "I got signed out again" — is indistinguishable from the general background noise of software being annoying. So it went unreported as a bug and got reported as a nuisance.
What we did not want to do
The obvious fix is to delete the check. That is worse than the bug. The comparison is the only thing standing between a stolen cookie and an authenticated session on the attacker's machine, and the fact that it was too strict is not an argument that it should be absent.
The second obvious fix is to make it a soft signal — count mismatches, terminate on the third. That trades a precise control for a fuzzy one and leaves an attacker two free requests. No.
The right question is narrower: which parts of that string represent the client, and which parts represent the build? An attacker replaying a cookie is on different hardware with a different browser family. They are not, generally, on the same browser one point release later.
Comparing identity, not build number
So the comparison normalises the version numbers out before comparing, and keeps everything else:
export function stableClientIdentity(userAgent: string): string {
return userAgent
.replace(/\/[0-9]+(?:[._][0-9]+)*/g, "")
.replace(/\s+/g, " ")
.trim()
.toLowerCase()
}
Applied to the example above, that collapses to mozilla (macintosh; intel mac os x 10_15_7) applewebkit (khtml, like gecko) version safari. Safari on that Mac stays Safari on that Mac across every point release. Chrome on Windows does not normalise to the same string, and neither does a scripted client, a different engine, or a different platform.
// the new comparison
if (stableClientIdentity(session.user_agent)
!== stableClientIdentity(userAgent)) {
// terminate
}
The control still fires on the case it was written for. It no longer fires on an auto-update.
What this cost, and the part worth taking away
The interesting failure here is not the regex. It is that a false positive can be invisible in exactly the logs you would look at. A terminated session leaves an event that says the session was terminated for fingerprint mismatch. Read one at a time, each event looks like the control working. It is only when you ask how many of them are Safari-to-Safari, one version apart, on the same IP, that the shape appears — and nobody asks that question, because the individual events look correct.
If you fingerprint sessions, the check is worth having. Just be deliberate about which fields you include, and treat any field a vendor can change without the user's involvement as a field that will eventually change without the user's involvement.