Skip to content

fix(auth): one active account per email, whatever its case - #57

Merged
GianniCarlo merged 3 commits into
mainfrom
fix/case-insensitive-email
Oct 14, 2026
Merged

GianniCarlo merged 3 commits into
mainfrom
fix/case-insensitive-email

Conversation

@GianniCarlo

@GianniCarlo GianniCarlo commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

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 but getUser matched 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) and PasskeyDB.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

  • Both lookups compare lower(email) = lower(?).
  • Migration 20261013120000_users_lower_email_active_unique: CREATE UNIQUE INDEX CONCURRENTLY on lower(email) WHERE active = true (with transaction: false, like 20260924120000). 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 predate external_id storage, 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

  1. Merge the 6 pairs (one-off script, separate from this PR).
  2. Deploy. If the old code creates a new pair in between, the migration fails and leaves an invalid index. Merge that pair and run the migration again: it drops the leftover index and builds it fresh.

Not in this PR

  • After this change, an Android passkey sign-up with the email of an Apple-only account gets the 409 "sign in instead". On Android that user can only sign in with a cross-device passkey from their iPhone.

Verified

  • tsc --noEmit passes.
  • Full jest suite on a local Postgres 17 container: 467/467 pass, and all 37 migrations apply on a fresh database.
  • The original first commit's tests fail against the old lookups (5 of them, including the end-to-end case: a lowercase sign-up against a mixed-case account now gets EMAIL_ALREADY_REGISTERED).
  • With a Dup@/dup@ pair present, the migration fails with Key (lower(email))=(dup@x.com) is duplicated. After one of the pair is deactivated, a re-run builds a valid unique index.
  • With 50k users loaded, EXPLAIN shows the lookup using the index.

🤖 Generated with Claude Code

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.
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

✅ Claude PR Review — PASS

Makes account lookups by email ignore case: UserDB.getUser and PasskeyDB.getUserByEmail now compare lower(email), with the value passed as a bound parameter, so there is no SQL injection risk. A new migration builds a unique index on lower(email) for active users (CREATE UNIQUE INDEX CONCURRENTLY, transaction: false). It first drops any invalid leftover index, the same pattern as 20260924120000, and drops nothing else, so no data can be lost. In initLogin, the token's email is chosen by a case-insensitive comparison of the provider's email and the account's stored one, and this runs after both the new-link and the already-linked paths. That resolves earlier finding #1: the stored-spelling S3 prefix now holds on every later sign-in, and a test covers it. Authorization / passkey: no route or middleware changes. Passkey registration still keys off the email bound to the challenge, which comes from a verified-email token or the logged-in user's JWT. The one behaviour change is that a verified passkey sign-up whose email differs only in case now links to the existing account instead of creating a duplicate. The email is verified, so this is the intended fix rather than a takeover risk. If two sign-ups race, the second insert now hits the unique index: insertUser returns null or the transaction rolls back, and the client gets a 500. Deployment depends on merging the 6 duplicate pairs first, as the PR description says.

Findings: no findings

Previously raised

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.
@GianniCarlo GianniCarlo changed the title fix(auth): match account emails regardless of case fix(auth): one active account per email, whatever its case Oct 14, 2026
Comment thread src/controllers/UserController.ts Outdated
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.
@GianniCarlo
GianniCarlo merged commit 86af25e into main Oct 14, 2026
4 checks passed

This branch was successfully deployed

1 active deployment
reviewer — 1a0c97d0 Deployed Oct 14, 2026 by GianniCarlo via review #115
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant