DEV Community

UlyssesDonovan1529
UlyssesDonovan1529

Posted on

FastAPI Signup Abuse Controls — Unifying Regional Email, Phone, and OAuth

Short answer: treat email, phone, and OAuth as verified login identifiers attached to one internal user, and put CAPTCHA at the risky signup boundary rather than letting each regional login choice create its own account.

For a cross-border health marketplace, the deciding constraint is abuse resistance without account fragmentation. A CAPTCHA can raise the cost of automated registration, but it cannot decide whether [email protected], +14155550123, and an OAuth subject belong to the same person. That decision needs an explicit identity-linking policy, a stable internal user ID, and a recovery path that does not quietly weaken the policy.

The simple design is tempting: one table for email users, another for phone users, and a third callback that creates OAuth users. It also fails at the exact moment a customer changes regions or chooses a different button. The safer design separates proof of an identifier from ownership of the account.

What should regional email, phone, and OAuth login choices share?

They should share an internal user record and nothing that can be inferred merely from matching text. The user ID is the durable key for carts, prescriptions, orders, consent records, and model-generated support summaries. Login identifiers are replaceable credentials around that key.

A compact model has three distinct objects:

Object Stable key What it represents
User Random internal ID The account and its application data
Login identity Provider plus provider-scoped subject A verified way to authenticate
Verification attempt Short-lived challenge ID Evidence gathered during signup or linking

For email, the provider can be an application namespace and the subject can be a normalized, verified address. For phone, store a verified number in a canonical international representation, but keep the raw user input separately if customer support needs it. For OAuth, the durable external identity is the authorization server's issuer plus its subject identifier; an email claim is account data, not a universal primary key. OpenID Connect defines iss and sub together as the locally unique, stable identifier for an end user.

That distinction matters. Two OAuth issuers may report the same email, and a recycled phone number may eventually reach a different person. Automatic linking on either value can turn a convenient login option into an account-takeover path. Require authentication to the existing account, or another strong proof already bound to it, before attaching a new identity.

Don't merge first and ask questions later.

Put CAPTCHA before account creation, not inside identity resolution

CAPTCHA answers one narrow question: did this interaction meet an anti-automation check? It does not prove legal identity, possession of an email inbox, control of a phone number, or ownership of an OAuth account. Keep those claims separate in code and telemetry.

An effective signup flow starts with low-friction signals, escalates suspicious attempts to a challenge, verifies the selected login identifier, and only then commits the user plus identity in one transaction. Existing users who pass OAuth authentication should go directly to their account; forcing a new CAPTCHA on every login can add accessibility and conversion costs without fixing duplicate-account logic. OWASP recommends adaptive or risk-based authentication and describes CAPTCHA as a defense-in-depth control, not a complete defense.

The catch is false positives. A traveler on a shared carrier network, a clinic using a common outbound IP, and a scripted attacker may look similar to a crude rule. Risk scoring therefore needs observable inputs, bounded retention, and a manual or alternate verification path. I'm not sure any fixed threshold transfers cleanly between regions; replay rate, challenge completion, and confirmed abuse from your own traffic are what resolve that uncertainty.

Keep the state machine explicit:

  1. Start a signup attempt and issue an opaque, expiring attempt ID.
  2. Evaluate abuse signals; request a CAPTCHA only when policy requires it.
  3. Verify the CAPTCHA server-side and bind the result to that attempt.
  4. Verify email, phone, or OAuth through its own protocol.
  5. Look up the provider-and-subject pair. Sign in if it exists.
  6. Otherwise, create a user and identity atomically, or ask an authenticated user to approve linking.

One boundary is non-negotiable — the browser must not be able to assert captcha_passed=true or choose the target user ID.

A focused FastAPI identity-linking boundary

The following example is deliberately small. It models the transaction boundary and duplicate protection, while leaving token validation, CAPTCHA verification, database access, rate limiting, and delivery behind injected interfaces. Those parts should use mature protocol libraries and a transactional database in production.

from dataclasses import dataclass
from typing import Protocol
from uuid import UUID

from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel


app = FastAPI()


class CompleteSignup(BaseModel):
    attempt_id: UUID
    provider: str
    proof: str


@dataclass(frozen=True)
class VerifiedIdentity:
    provider: str
    subject: str


class SignupStore(Protocol):
    def captcha_passed(self, attempt_id: UUID) -> bool: ...
    def find_user(self, identity: VerifiedIdentity) -> UUID | None: ...
    def create_user_with_identity(self, identity: VerifiedIdentity) -> UUID: ...


class IdentityVerifier(Protocol):
    def verify(self, provider: str, proof: str) -> VerifiedIdentity: ...


store: SignupStore
verifier: IdentityVerifier


@app.post("/signup/complete", status_code=status.HTTP_201_CREATED)
def complete_signup(request: CompleteSignup) -> dict[str, str]:
    if not store.captcha_passed(request.attempt_id):
        raise HTTPException(status_code=403, detail="Signup challenge required")

    identity = verifier.verify(request.provider, request.proof)
    existing_user = store.find_user(identity)
    if existing_user is not None:
        return {"user_id": str(existing_user), "outcome": "signed_in"}

    user_id = store.create_user_with_identity(identity)
    return {"user_id": str(user_id), "outcome": "created"}
Enter fullscreen mode Exit fullscreen mode

The database must enforce a unique constraint on (provider, subject). Application-level lookup is useful for the common path, but two concurrent callbacks can both observe “missing.” The constraint is the final guard; handle its conflict by reading the already-linked user and returning the normal signed-in result. Do not create a second user and queue a cleanup job. For an intentional link to an existing account, use a separate endpoint that requires a fresh session and reauthentication rather than accepting a user ID in this signup payload.

Notice what the endpoint does not do. It does not trust an OAuth email to merge records, let an unverified phone number claim a user, or treat a successful CAPTCHA as authentication. A rejected challenge returns 403, while malformed or expired identity proof should be rejected by the verifier without mutating account state. These distinctions make logs and eval cases much easier to interpret.

Test the collisions, not just the happy paths

Notebook-to-prod auth work benefits from an eval set the same way an agent feature does: encode the dangerous edge cases before changing the policy. Start with a synthetic matrix, never production credentials. Include two concurrent requests for the same provider subject, the same email claim from different OAuth issuers, a phone number entered in two display formats, an expired signup attempt, a valid identity proof after a failed challenge, and an authenticated user deliberately adding a second login method.

The expected invariants are sharper than a generic “signup succeeded” assertion. One external identity maps to at most one internal user. A failed or absent challenge creates nothing. Replaying a completed attempt creates nothing. Adding a login identifier never transfers application data between users. Recovery cannot attach a new identifier with weaker proof than the account's current risk warrants.

Measure outcomes by region and login method, but don't optimize challenge completion alone. Track challenge rate, completion rate, verified-signup rate, duplicate-account reports, identity-link conflicts, recovery starts, recovery completions, and confirmed abusive registrations. Attach a policy version to each decision so a threshold change can be evaluated instead of guessed at. Keep tokens, CAPTCHA responses, one-time codes, and raw model prompts out of logs; OWASP's logging guidance treats authentication successes and failures as security-relevant events, while sensitive authentication data should not be recorded.

A prompt-cost-aware team should also keep authentication deterministic. A model may summarize abuse cases for an analyst after redaction, but it should not decide whether two people are the same user. That choice requires reproducible evidence, explainable rules, and an auditable state transition.

Choose regional methods by failure and recovery cost

There is no universal winner. Email works across many devices but depends on inbox access and delivery. SMS offers a familiar possession check, yet NIST treats use of the public switched telephone network for out-of-band authentication as restricted and asks verifiers to consider risks such as number reassignment and SIM change. OAuth can reduce new-password handling, but account availability and recovery then depend partly on an external authorization server. CAPTCHA adds friction and has accessibility implications; WCAG requires a non-cognitive-function alternative when an authentication step relies on a cognitive function test, with defined exceptions.

Choose the smallest regional set that customers can both enter and recover. A phone-first flow is not suitable where delivery coverage or number stability is weak; use verified email or an appropriate federated option there. An OAuth-first flow is a poor fit when customers cannot reliably retain access to that external account; keep a separately verified recovery method. A CAPTCHA-first policy is the wrong default for every visitor when measured abuse is low or accessible completion cannot be supported; prefer rate limits and risk-triggered challenges.

Before copying this design, run the synthetic collision suite, verify the database uniqueness behavior under concurrency, and compare verified signups with confirmed abuse by region. The useful result is not the highest CAPTCHA pass rate. It is fewer abusive accounts without creating duplicate users or trapping legitimate customers outside recovery.

References

Top comments (0)