DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Counting devices by cookie locked out the wrong people

We sell one-time unlocks, not subscriptions. That makes account sharing the obvious way to get the product for half price, so accounts are capped at two devices.

The first implementation was the one everybody writes: put a random UUID in a long-lived cookie, store a row per token, refuse the third row.

It was wrong, and the way it was wrong is instructive, because every failure pushed in the same direction: locking out real customers while barely inconveniencing anyone actually sharing an account.

A cookie is not a device

Once a slot belongs to a cookie value, anything that costs a device its cookie costs the user a slot. In practice that means:

  • Clearing site data.
  • A private window.
  • Reaching the app on a second hostname (example.com versus www.example.com).
  • Two concurrent first requests, each minting a token before either had committed. One physical device, two rows, instantly.

That last one is the worst, because it happens on a normal first visit with a page that fires a couple of parallel requests. A brand new user could burn both slots before they had seen a single screen, and then their real second device got refused as if it were a third.

Support tickets from people who had done nothing wrong. Meanwhile the actual sharing case was barely affected.

Second identifier, used only as a fallback

The fix was to give a device a second, coarser identity and use it to recover a slot the device already owns:

1. device_token     random UUID in a 1-year httpOnly cookie.
                    Exact and authoritative when present.
2. device_fingerprint  sha256 of the user-agent. A FALLBACK identity,
                       used only when the token is missing or unrecognised.
Enter fullscreen mode Exit fullscreen mode

The rules became pure functions over the rows, which is the single most useful decision in this whole thing:

export function decideDeviceSlot(
  sessions: readonly DeviceSlotRow[],
  candidateToken: string,
  fingerprint: string,
  maxDevices: number = MAX_DEVICES_PER_USER
): SlotDecision {
  const exact = sessions.find((s) => s.deviceToken === candidateToken)
  if (exact) return { action: 'refresh', token: exact.deviceToken }

  // Same device, different (or lost) cookie. Reclaim the slot it already holds
  // instead of spending a second one. Most recently active wins if the old
  // behaviour left several rows behind for this fingerprint.
  const sameDevice = sessions
    .filter((s) => s.deviceFingerprint === fingerprint)
    .sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime())[0]
  if (sameDevice) return { action: 'adopt', token: sameDevice.deviceToken }

  // Count devices, not rows.
  const distinctDevices = new Set(sessions.map((s) => s.deviceFingerprint))
  if (distinctDevices.size < maxDevices) return { action: 'insert', token: candidateToken }

  return { action: 'deny' }
}
Enter fullscreen mode Exit fullscreen mode

Two things in there matter more than they look.

adopt writes the token back. An earlier attempt at fingerprint matching let a device through on a fingerprint match and then inserted a new row anyway, because the token still did not match. That is worse than no matching at all: it grants access and leaks a row every time. The version that works returns the matched row's token, and the caller sets that token in the cookie, so token and row converge and the next request is a plain refresh.

The cap counts distinct fingerprints, not rows. This is what quietly repaired every account that had already accumulated junk rows under the old behaviour. No backfill, no migration, no support queue. The moment the counting rule changed, those accounts stopped being penalised for a bug we had shipped.

If you can fix historical data by changing how you read it rather than by rewriting it, do that. Read-side fixes are reversible. Migrations are not.

The cost we accepted, stated out loud

Hashing the user-agent is coarse. Two genuinely different iPhones on the same iOS and Safari build hash identically and share one slot.

We took that deliberately. The failure direction is "the cap is occasionally softer than intended", not "a paying customer is locked out of their own account". Two different browsers on one machine still take two slots, because their UAs differ.

The fingerprint is also, straightforwardly, low-fidelity. That is fine here. This is a friction mechanism, not a security boundary, and it is worth being honest with yourself about which one you are building. We do not use this value for anything that matters: our referral fraud checks explicitly refuse to touch it, for exactly this reason.

While we are here, sha256(user-agent) tells you nothing reliable about hardware. Chrome and Safari report Intel Mac OS X on Apple Silicon. If you render a device name from this, render it as a guess.

Two races, two different tools

Race one: two requests, one account, one free slot. Both read "one device registered", both insert, account is over its own cap. A Postgres transaction-scoped advisory lock serialises claims per account:

export function slotLockKey(userId: string): string {
  const digest = crypto.createHash('sha256').update(userId).digest()
  const high = digest.readUInt32BE(0) % 0x200000 // 21 bits
  const low = digest.readUInt32BE(4)             // 32 bits

  return String(high * 0x100000000 + low)
}
Enter fullscreen mode Exit fullscreen mode

53 bits, so it is an exact integer in a double without reaching for BigInt, and comfortably inside the signed bigint the lock function takes. Two accounts colliding on a key merely serialise against each other, which is harmless. Transaction-scoped means it releases on COMMIT or ROLLBACK, which is what makes it safe behind a transaction pooler that does not preserve session state.

Race two: the denied device retrying. A device sitting on the "device limit reached" wall has no cookie, so it presents a fresh token on every request, and every one of those opens a claim transaction. A small in-memory cache with a five second TTL handles it, and the key choice is the whole trick:

Positive results are keyed by TOKEN.
Denials are keyed by FINGERPRINT.
Enter fullscreen mode Exit fullscreen mode

Cache a denial by token and you cache nothing, because the token is different every time. Keyed by fingerprint, the denied device gets one database round trip per five seconds instead of one per request.

The cache is per-instance and deliberately not Redis. Five seconds of staleness on a device check is not worth a network hop on every authenticated request.

See it behave

Free account, no card needed. Sign up at cogniprep.app/signup and then:

  1. Sign in normally. That is device one.
  2. Open a private window and sign in again. Under the naive implementation this counts as a second device. Here it adopts the same slot, because the user-agent matches.
  3. Now use a genuinely different browser. That is device two, correctly.
  4. A third browser reaches the limit page, and you can free a slot yourself from Settings.

Step 2 is the one the first implementation got wrong, and it is the one that generated every support ticket.

What I would keep

  • Split the rules from the SQL. decideDeviceSlot is a pure function over rows and a candidate. The reported bug was in the rules, not the queries, and pure rules are exhaustively testable in milliseconds.
  • Make the identity hierarchy explicit. One authoritative identifier, one fallback, and a written rule for when the fallback applies. "We check a few things" is how you get the grant-and-also-insert bug.
  • Write down the accepted cost. The comment saying two identical iPhones share a slot is not an apology, it is the record of a decision, so the next person does not "fix" it and start locking people out again.
  • Pick the failure direction on purpose. For a friction mechanism, always fail open toward the legitimate user. What you protect by failing closed is smaller than what you lose from a customer who cannot get into the thing they paid for.

Top comments (0)