Short answer: create the user record and immutable user ID before the captcha decision is finalized, then use a normalized email address only for operational lookup, never as the account identity. During a managed-provider migration, preserve that ID in your mapping table and make the captcha result an auditable event attached to it.
That ordering sounds fussy until a marketplace is under registration pressure. A bot submits an address, the captcha provider times out, and an operator later searches by email while the migration job has already assigned a new identifier. You now have two records that look like one person. The storage layer did exactly what it was asked to do; the identity model was vague.
Start with the record, not the challenge
The signup transaction should reserve an internal identifier, write a minimal account row, and attach a short-lived registration state. The state can be captcha_pending, captcha_passed, or captcha_rejected; it is not a replacement for the account's identity. A retry must reuse the same registration attempt rather than insert another user row.
For a migration, keep the old provider's subject in a separate, encrypted mapping table. It is useful evidence during reconciliation, but it is not a public key for marketplace orders. Public URLs, order ownership, audit records, and authorization checks should all point to the internal ID. Email belongs in a searchable operations index with strict access controls.
Here is a deliberately small transaction boundary. It does not call a particular captcha vendor, which is useful because the challenge service is the part most likely to change during migration.
from dataclasses import dataclass
from enum import StrEnum
from uuid import UUID, uuid4
class SignupState(StrEnum):
CAPTCHA_PENDING = "captcha_pending"
CAPTCHA_PASSED = "captcha_passed"
CAPTCHA_REJECTED = "captcha_rejected"
@dataclass(frozen=True)
class Signup:
user_id: UUID
email_lookup: str
state: SignupState
old_subject: str | None = None
def begin_signup(raw_email: str, old_subject: str | None = None) -> Signup:
email_lookup = raw_email.strip().casefold()
if not email_lookup or "@" not in email_lookup:
raise ValueError("invalid email")
return Signup(uuid4(), email_lookup, SignupState.CAPTCHA_PENDING, old_subject)
The normalization is intentionally modest. Providers disagree about whether dots, plus tags, or Unicode variants are equivalent, so an application should not silently rewrite those forms unless its documented policy and support tooling agree. Store the original address separately when it is needed for notices; never use a display string as a join key.
What should stable IDs and email lookup mean during migration?
The stable ID is a durable join key. The email index is a human-facing search aid. Keeping those jobs separate makes the migration measurable: every imported account can be checked for exactly one internal ID, at most one active email index entry, and a migration mapping that points to the same account.
The failure modes are ordinary and expensive:
| Boundary | Failure mode | Control | Cost of the control |
|---|---|---|---|
| Captcha callback | A delayed callback creates a second account | Idempotency key on the signup attempt | Retain pending attempts briefly |
| Email search | Case or whitespace creates duplicate hits | Case-folded, indexed lookup plus exact display value | Operators need a clear “normalized” label |
| Provider subject | Re-import overwrites a local account | Unique mapping on (provider, old_subject)
|
A reconciliation queue for collisions |
| Deletion | Search index keeps an address after account removal | Transactional tombstone and index purge | Recovery requires an audit trail |
I once started a migration review by comparing email counts. That was the wrong measure. A shared family address and a changed address can both make the count look healthy while order ownership is already split. The useful report compares IDs, provider subjects, signup attempts, and captcha decisions, with a sample of records inspected by an operator. I’m not sure any automated report can prove identity for the last ambiguous row; that is precisely why the queue and its evidence should be designed before the cutover.
Three words: make it idempotent.
def apply_captcha_result(store, signup_id: UUID, passed: bool, event_id: str) -> Signup:
with store.transaction():
if store.has_event(event_id):
return store.get_signup(signup_id)
signup = store.get_signup_for_update(signup_id)
next_state = SignupState.CAPTCHA_PASSED if passed else SignupState.CAPTCHA_REJECTED
updated = store.set_state(signup.user_id, next_state)
store.record_event(event_id, signup.user_id, next_state.value)
return updated
The event ID is from the registration attempt, not from an email address. That distinction prevents a retry, a browser refresh, and a provider callback from competing to create identity. Log the decision, timestamp, policy version, and internal ID; avoid logging the raw challenge token.
A migration runbook that operators can verify
First, freeze the identity contract: define which table owns user_id, which fields can change, and how an old subject maps to it. Second, dual-read the old and new lookup paths while writes still go to the old provider. Compare results by internal ID, not by email text. Third, backfill the mapping table in batches with a resumable cursor and a collision queue. Fourth, switch new signups to the new captcha boundary while retaining the old subject as evidence. Finally, remove the old read path only after reconciliation reports zero unexplained ownership differences for a defined observation window.
Metrics should expose decisions, not secrets: pending attempts by age, duplicate mapping candidates, captcha rejection rate, callback latency, and operator queue size. Alert on a rising pending age or a mapping collision, not on an individual address. A 2026 migration plan that cannot replay one signup from audit events is not ready for a marketplace with real orders.
The catch is retention. Keeping old subjects and captcha events makes support and rollback possible, but it also increases the amount of personal data you must protect and eventually delete. This design is not suitable when your policy forbids retaining provider identifiers after cutover; in that case, export a one-time, access-controlled reconciliation report, destroy the subject mapping on schedule, and accept that later account recovery will rely on stronger manual evidence. Stick with a provider-native identity key when you cannot operate that retention and audit process.
References
- https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- https://www.rfc-editor.org/rfc/rfc6749
- https://www.rfc-editor.org/rfc/rfc9110
Top comments (0)