Marketplace login risk is easiest to manage when identity and operations use different keys. Keep an immutable user ID as the subject of authentication and device-fingerprint decisions; treat email as a mutable, verified contact attribute for support and messaging. That split preserves session security without turning an address change into an account migration.
Short answer: look up the account by a stable user ID after authentication, and use a normalized, verified email only for operational workflows such as receipts, recovery notices, and agent search.
The constraint: a login signal is not a contact field
A device fingerprint is a risk signal, not an identity. It can change after a browser update, a privacy setting, or a shared household device. The account record therefore needs a durable subject (user_id) and a separate event record for the signal. Email belongs in the profile and notification tables, with verification state and timestamps.
This matters in a marketplace because one person can be a buyer, a seller, or both. A seller may rotate the address used for invoices while keeping open orders and payout history. If an email is the primary key, that ordinary operation becomes a dangerous cascade of foreign-key rewrites and stale caches.
I learned this while dealing with OTP delivery gaps: an address can be syntactically valid, verified yesterday, and still be unreachable today. The safe response is to record the delivery attempt and its provider-independent status, not to mint a second account because a message bounced.
Use a unique internal ID generated once, and make every security-sensitive row reference it. Store email in a canonical form for comparison, but retain the original display value. Normalization rules should be explicit; lowercasing is common, while provider-specific transformations such as removing dots or plus tags can merge distinct mailboxes.
How should user IDs, identity checks, and email operations interact?
The request path should make the boundary visible. Authentication resolves credentials to a user ID; risk scoring consumes that ID plus a fingerprint hash; operations search a separately indexed email column and then confirm the resulting user ID before acting.
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
@dataclass(frozen=True)
class LoginContext:
user_id: str
fingerprint_hash: str
observed_at: datetime
def choose_challenge(risk_score: int, trusted_session: bool) -> str:
"""Return a policy decision; the thresholds belong in reviewed config."""
if trusted_session and risk_score < 40:
return "allow"
if risk_score < 70:
return "step_up"
return "deny_and_review"
def resolve_for_operation(email: str, email_index: dict[str, str]) -> Optional[str]:
"""Email search returns a subject; it never becomes the subject itself."""
normalized = email.strip().casefold()
return email_index.get(normalized)
The code deliberately returns a subject before a policy decision. A support agent can find an account by email, but the action log should say which user_id was changed, who changed it, and why. That audit trail is what lets you distinguish a legitimate address update from an account-takeover attempt.
Keep the fingerprint input bounded as well. Hash a stable, documented subset of signals, rotate the salt on a schedule, and avoid collecting fields that are not needed for the fraud decision. A score should expire or decay; a device observation from six months ago should not silently lock a current seller out.
Failure modes that look like successful lookups
The most expensive incidents are the ones that return HTTP 200. A login handler that creates a new row when an email lookup misses can split order history. A case-insensitive query that ignores Unicode normalization can route a recovery message to the wrong record. A cache keyed only by email can serve an old risk decision after the address changes. Those mistakes are quiet: the buyer sees a normal page, the seller sees a normal receipt, and the support dashboard looks healthy until someone compares two timelines. I now trace one test account through signup, an address change, a new device, and a recovery request before trusting a migration. It catches the duplicate-row path that unit tests tend to miss, especially when a retry lands between the email write and the risk-event write.
Rate limits add another edge. Apply them to several dimensions: user ID, source network, device signal, and recovery destination. I have seen a correct per-account limit defeated by rotating addresses; the attacker never exceeded the limit for any one email, but the marketplace still absorbed the OTP traffic and the support queue.
For each failed lookup, return the same external response shape as a successful one where disclosure would help enumeration. Internally, emit a reason code such as EMAIL_UNVERIFIED, NO_SUBJECT, or RISK_REVIEW. Keep those codes out of user-facing copy unless the product and legal teams have approved the disclosure.
Short version: the status can be boring.
The boundary is easier to review in a small policy table:
| Concern | Stable user ID | Email address |
|---|---|---|
| Authentication subject | Yes | No |
| Device-risk history | Yes | No |
| Receipt or alert destination | No | Yes, after verification |
| Support search key | No, use as confirmation | Yes, with an exact-match policy |
| Account deletion audit | Yes | Snapshot only |
The table is a policy artifact, not a database shortcut. Document who may change an email, which re-verification is required, and how an active session reacts. OWASP recommends reauthentication after high-risk events; changing a recovery address qualifies because it changes the path back into the account. That's the security boundary.
Rollout: prove the split before enforcing it
Start by adding user_id to risk, session, and audit records while continuing to read the legacy email field. Backfill from the authoritative account table, then run a report for duplicate normalized addresses, unverified addresses, and rows with no subject. Do not silently pick a winner; send ambiguous records to a review queue.
Next, dual-write new events and compare decisions for a week. Measure challenge rate, successful step-up completion, false-positive reviews, OTP delivery outcomes, and support corrections. Your mileage may vary: marketplace traffic, regional privacy settings, and shared devices make a universal threshold unlikely.
The catch is operational complexity. A tiny internal tool that only sends receipts may be fine with email as its search input, while a payout console or account-recovery service should stick with user IDs and an explicit confirmation screen. This design is not suitable when your system cannot maintain an audit log or verify ownership of a new address; add those controls first.
Once the comparison is stable, make the user ID mandatory for new security writes, invalidate caches by subject, and keep email indexes for operations. The migration is complete when changing an address updates notifications without changing identity, risk history, sessions, or order ownership.
Top comments (0)