Repository navigation
fix(auth): one active account per email, whatever its case - #57
Conversation
Account lookups by email were exact, but Apple returns the address with the casing the user gave their Apple ID, while an email typed into the apps is usually lowercase. The passkey sign-up's "already registered" check lowercases the address before looking it up, so it never found a mixed-case Apple account: an iOS user who then signed up with a passkey on Android got a second, empty account, and bought a second subscription on it. UserDB.getUser and PasskeyDB.getUserByEmail now compare lower(email). Active accounts that differ only in case already exist, so the exact spelling is preferred, then the older account: every lookup that matched before returns the same row. Linking Apple or Google to an existing account by email can now find an account spelled differently, so that login's token carries the account's stored email. For accounts that predate external_id storage the token's email is the S3 prefix, and the provider's spelling would point at an empty one. Logins with a credential the account already has keep the provider's email, as before. A migration adds a partial index on lower(email) for active accounts; it can't be unique while the case-duplicate pairs remain.
✅ Claude PR Review —
|
| Finding | Status |
|---|---|
src/controllers/UserController.ts:149 (warn) ⚠︎ moved |
✅ verified fixed in 1a0c97d |
Converged: nothing new this round, and every earlier finding is settled.
Model claude-opus-5-5 · run log · 0 new · 0 carried over · 1 verified closed · 0 resolved · advisory (a human should still review). Findings are de-duplicated across pushes; an earlier finding closes only when the verification pass judges it against the current code — fixed, no longer applicable, accepted by a maintainer, or a duplicate of a finding reported on this push.
The six pairs of active accounts whose emails differ only in case are merged before this deploys, so the lower(email) index can be unique and the lookups no longer need an order to pick one of a pair. The migration fails, naming the email, while any pair is left; it can run again once that pair is merged.
Later sign-ins with a credential linked across a case difference took the existing-credential branch and put the provider's spelling back in the token, pointing older accounts at an empty S3 prefix. The token now carries the account's email whenever the two match ignoring case, on either branch; a provider email that changed altogether is still kept.
Important
Deploy only after the 6 case-duplicate account pairs in prod are merged. The migration builds a unique index and fails, naming the email, while any pair remains.
Problem
A user signed in on iOS with Apple, which stored their email with the casing of their Apple ID (
Jane@example.com). Minutes later they signed up on Android with a passkey, typing the email in lowercase. The sign-up's "already registered" check lowercases the address butgetUsermatched it exactly, so it found nothing. A second, empty account was created, and the user bought a second PRO subscription on it.Every account lookup by email had the same exact match:
UserDB.getUser(used by the email-verification check and by linking Apple/Google to an existing account) andPasskeyDB.getUserByEmail(used by passkey registration and authentication options). Prod has 6 active pairs that differ only in case, all created since passkeys shipped in January.Change
lower(email) = lower(?).20261013120000_users_lower_email_active_unique:CREATE UNIQUE INDEX CONCURRENTLYonlower(email) WHERE active = true(withtransaction: false, like20260924120000). This allows one active account per email whatever its case, so a lookup can't match two accounts. It also covers two sign-ups racing, where the second insert is rejected.initLogin: when a credential is newly linked to an existing account by email, the token carries the account's stored email instead of the provider's. For accounts that predateexternal_idstorage, the token's email is the S3 prefix, and a differently cased one would point at an empty prefix. Logins with a credential the account already has keep the provider's email, unchanged.Rollout
Not in this PR
Verified
tsc --noEmitpasses.EMAIL_ALREADY_REGISTERED).Dup@/dup@pair present, the migration fails withKey (lower(email))=(dup@x.com) is duplicated. After one of the pair is deactivated, a re-run builds a valid unique index.EXPLAINshows the lookup using the index.🤖 Generated with Claude Code