DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our concurrent login cap asked a question that was always true

We sell a hosting tool to pubs. One account, one venue, one person running the quiz. The thing that erodes that model is a login being passed around until nine venues are running off one subscription, so there is a cap on how many browser sessions an account can have signed in at once.

The cap was in production for months. It never once stopped anybody. Here is why, because the failure is a genuinely instructive shape and not just a bug.

The original data model

Sessions were tracked in Redis as a plain set of session hashes, plus one key per session for expiry:

pt_sess:user:{userId}        SET of sessionHash
pt_sess:{sessionHash}        a hash with a 30 day TTL
Enter fullscreen mode Exit fullscreen mode

On every authenticated dashboard request, the middleware hashed the JWT jti, added it to the set, counted the members, and decided whether to allow.

Stated like that you can almost see it. The set had no ordering. Members are unordered by definition, and the per session keys expired independently, so the code could ask "how many sessions does this user have" but never "which of them is the oldest".

Without ordering, a cap is unenforceable. If the account is at its limit and a new device signs in, you have to either refuse the new one or evict a specific old one, and "a specific old one" is a question a set cannot answer.

So the check degenerated. It added the current session hash to the set, then asked whether the account was allowed, and the way it answered that reduced to checking that the session it had just added was present.

It always was. SADD then SISMEMBER on the same member is not a question.

Meanwhile the thing cost, per dashboard request: an SADD, an SMEMBERS, and an EXISTS for every tracked session in order to prune the expired ones. A user with five stale sessions was paying seven Redis round trips per page load to evaluate a tautology.

That is the worst quadrant to be in. Not a feature that does not work, a feature that does not work and has a bill.

Sorted sets, because the timestamp is the point

The replacement is one key:

pt_sess:sessions:{userId}   ZSET  member = sessionHash, score = last-seen epoch ms
Enter fullscreen mode Exit fullscreen mode

A sorted set gives you the ordering the whole feature depends on, and it makes expiry a range delete instead of N existence checks. The full enforcement is now three commands in one pipeline, then two more only if the session is allowed:

const hash = await sha256Hex(jti)
const sessionsKey = `${KEY_PREFIX}:sessions:${userId}`
const now = Date.now()
const cutoff = now - SESSION_TTL_S * 1000

const pruned = redis.pipeline()
pruned.zremrangebyscore(sessionsKey, 0, cutoff)  // drop anything past its TTL
pruned.zscore(sessionsKey, hash)                 // is this session established?
pruned.zcard(sessionsKey)                        // how many are live?
const [, existingScore, liveCount] = await pruned.exec()

const isEstablishedSession = existingScore !== null && existingScore !== undefined

if (!isEstablishedSession && liveCount >= maxSessions) {
  return false
}

const touch = redis.pipeline()
touch.zadd(sessionsKey, { score: now, member: hash })
touch.expire(sessionsKey, SESSION_TTL_S)
await touch.exec()

return true
Enter fullscreen mode Exit fullscreen mode

The ordering of operations is the fix. Read before you write. The old code added first and asked afterwards, which is what made the question meaningless. Here zscore and zcard both run before the zadd, so liveCount is the number of other sessions and existingScore genuinely distinguishes "you are already signed in here" from "this is a new device".

zremrangebyscore replaces the whole prune loop with one range delete by score, which is the reason to reach for a ZSET even if you did not need the ordering for the policy.

Refuse the newest, never evict the established

The policy question that the data model finally made answerable: when an account is at its cap and a new login arrives, who loses?

We refuse the new one.

The alternative, evicting the oldest session to make room, is what a lot of products do and it is a poor fit here. The oldest session is very likely the tablet behind the bar that is currently hosting a quiz for ninety people. Evicting it because somebody signed in at home is a catastrophic outcome caused by a trivial action.

So an established session is never displaced. It refreshes its timestamp and continues. The person adding a device is the one who gets the message, and they are the one with the context to understand it, because they just did something.

if (!isEstablishedSession && liveCount >= maxSessions) is that entire policy, and it only compiles into something meaningful because the ZSET can tell those two states apart.

When it does refuse, the auth cookies are cleared on the way out so the browser does not spend the next ten requests retrying a session it will never be allowed to keep:

const url = request.nextUrl.clone()
url.pathname = '/login'
url.searchParams.set('reason', 'session_limit')
const logoutResponse = NextResponse.redirect(url)

request.cookies.getAll().forEach(({ name }) => {
  if (name.startsWith('sb-')) logoutResponse.cookies.delete(name)
})
return logoutResponse
Enter fullscreen mode Exit fullscreen mode

The reason parameter exists so the login page can explain what happened. A silent logout is indistinguishable from a bug, and users report it as one.

Two smaller things worth stealing

Only run it where it matters. The check is scoped to /dashboard paths. The player facing routes, where a hundred phones are hammering the server during a quiz, never touch Redis for session tracking. A concurrent login cap on an anonymous player would be nonsense anyway, and the hot path stays free of the round trip.

Hash the jti, do not store the token. The key is a SHA-256 of the JWT's jti claim, computed with Web Crypto so it runs in the Edge runtime. A leak of the Redis contents is then a list of opaque hashes rather than anything resembling a credential.

Fail open. Both the missing client case and the error case return true. A Redis wobble that logs a paying customer out of the dashboard mid quiz is far worse than a few minutes of unenforced cap.

The test that would have caught it

None existed, and this is the part I actually changed my habits over. We had no test asserting that the (N+1)th login is refused, because writing one requires setting up N logins, and the feature "looked fine" in manual testing. Of course it did. Manual testing of a cap of three means signing in on a fourth device, which nobody does on a Tuesday.

If a limit has no test that crosses it, assume it does not work. It is the single easiest class of feature to ship broken, because the happy path and the broken path are the same path.

Seeing it from the outside

The cap is three on the paid plans, and the value written for a lapsed account is two, one venue device plus one at home. A brand new free account has no cap key yet, because only the Stripe webhook writes one, so it lands on the fallback of three described above. That is the fallback doing exactly the job it was chosen for.

The plan limits that sit beside it, quizzes per day, tables, custom packs and host accounts, are all published on the pricing page, which is the readable version of the same table the enforcement code reads.

If you want to test the thing this post is about, sign up free at pub-trivia.app, no card needed, then open the dashboard in a second browser, a private window and a phone. The third goes through. The fourth lands back on the login page with an explanation, and every tab you were already using keeps working, which is the "never evict the established session" rule doing its job.

Top comments (0)