DEV Community

tony chen
tony chen

Posted on

5 Recovery Rules for Signup Bot Defense — CAPTCHA Before Account Creation

Signup bot defense is an account-lifecycle decision, not a widget decision. For a property-management app, verify CAPTCHA at the server-side boundary immediately before account creation, but model that result as one auditable state transition rather than proof of identity.

Short answer: create an account only after server-side CAPTCHA verification succeeds; keep identity verification separate, combine the decision with rate limits, device signals, and risk scoring, and give legitimate residents a recovery path.

That rule matters beyond signup. A property manager eventually has to delete an account for GDPR and revoke every session, so the initial registration flow should produce states that can be inspected, reversed, or erased without guessing what a single verified boolean meant.

1. Put CAPTCHA next to the protected action

The browser may render the challenge, but it is not the trust boundary. Send the challenge result to your backend, verify it there, and permit the account-creation transition only after an accepted verification. A rejected attempt should remain an attempt, not become a partial resident account that support later has to untangle.

No half-accounts.

In a property-management flow, the useful sequence is narrow: receive the signup request, verify the challenge, evaluate the other risk signals, create the user, and then continue with identity verification. CAPTCHA success does not prove that the applicant owns an email address, belongs to a tenancy, or is entitled to a building. Those are separate claims with separate evidence. Keeping them apart also makes GDPR deletion easier to reason about: account removal and revocation of every session are explicit later transitions, rather than cleanup hidden inside the original signup handler.

Record a correlation ID, timestamp, transition name, and bounded decision reason for the audit trail. Do not retain the raw challenge token longer than the application's retention policy allows. The interesting eval fixture is not just challenge accepted; it is challenge accepted, identity still pending, duplicate submit received, because that is where notebook logic tends to become ambiguous in production.

2. Run the two-step gate as one application workflow

The safest implementation keeps verification and creation in one backend workflow while treating them as distinct remote actions. The example below uses the two documented routes, sets every HTTP method explicitly, honors Retry-After on 429, checks all response statuses, and uses an idempotency key for the write. It also asks the public discovery surface for each operation's current JSON Schema, avoiding guessed request or response fields.

import os
import time
import uuid
from typing import Any

import jsonschema
import requests


API_ORIGIN = os.environ["BACKEND_API_ORIGIN"]


def retry_delay(response: requests.Response, attempt: int) -> float:
    value = response.headers.get("Retry-After")
    if value is not None:
        try:
            return max(0.0, float(value))
        except ValueError:
            pass
    return float(2**attempt)


def request_json(
    method: str,
    url: str,
    *,
    payload: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
    attempts: int = 4,
) -> dict[str, Any]:
    for attempt in range(attempts):
        response = requests.request(
            method=method,
            url=url,
            json=payload,
            headers=headers,
            timeout=10,
        )
        if response.status_code == 429:
            time.sleep(retry_delay(response, attempt))
            continue
        if not response.ok:
            raise RuntimeError(
                f"request failed ({response.status_code}): {response.text}"
            )
        return response.json()
    raise RuntimeError("request remained rate-limited after four attempts")


def operation_schema(capability: str) -> dict[str, Any]:
    return request_json(
        "GET",
        f"{API_ORIGIN}/v1/discovery/{capability}",
    )


def verified_post(
    capability: str,
    path: str,
    payload: dict[str, Any],
    idempotency_key: str | None = None,
) -> dict[str, Any]:
    contract = operation_schema(capability)
    jsonschema.validate(payload, contract["params"])
    headers = {
        "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
        "Content-Type": "application/json",
    }
    if idempotency_key is not None:
        headers["Idempotency-Key"] = idempotency_key
    result = request_json("POST", f"{API_ORIGIN}{path}", payload=payload, headers=headers)
    jsonschema.validate(result, contract["response_schema"])
    return result


def verify_then_create(
    captcha_payload: dict[str, Any],
    user_payload: dict[str, Any],
) -> dict[str, Any]:
    verified_post("captcha.verify", "/v1/captcha/verify", captcha_payload)
    return verified_post(
        "auth.user.create",
        "/v1/auth/user/create",
        user_payload,
        idempotency_key=str(uuid.uuid4()),
    )
Enter fullscreen mode Exit fullscreen mode

Install requests and jsonschema, set BACKEND_API_ORIGIN and INFRAI_API_KEY, and pass payloads that validate against discovery. The function deliberately does not invent a field such as success: the current contract defines the response, while the application's adapter must map an accepted verification into its own captcha_verified transition. Do that mapping in one place and cover it with an eval fixture.

There is still a transaction boundary to acknowledge. CAPTCHA verification and user creation are separate calls, so your application must not interpret a verified challenge as an existing account. Give the challenge decision a short-lived application state, bind it to the signup attempt, and ensure a repeated create submission reuses the same idempotency key. Don't generate a fresh identity because the network answer was uncertain.

3. How should server-side CAPTCHA verification gate signup before account creation?

Treat the gate as a policy decision with three possible application outcomes: proceed, issue a fresh recoverable challenge, or deny the attempt. A challenge failure should block automation near the protected action, yet a real tenant who mistypes, uses assistive technology, or arrives from a crowded office network needs a way to try again without inheriting a damaged account state.

This is where the recovery axis changes the design. Rate limits, device signals, and risk scores add context that CAPTCHA alone cannot provide. A burst of 200 invitations from one property-management office can resemble automation at the IP layer; a script rotating addresses can look quiet by the same measure. Combine signals, keep the policy explainable, and evaluate false positives against representative fixtures before tightening it. I'm not sure any universal threshold would survive both a small residential portal and a nationwide operator. Traffic evidence from your own signup funnel resolves that uncertainty.

The catch is friction. Aggressive challenges may stop more automated attempts while also increasing abandonment and accessibility problems. A low-risk resident might receive a quiet challenge; a high-risk attempt might need a new challenge plus email verification. Neither path may skip identity verification, because CAPTCHA answers a narrower question: did this request satisfy the challenge policy?

Fast isn't enough.

4. Compare recovery paths, not feature counts

Provider choice should follow the recovery and ownership model already in the app. Auth0, Clerk, Supabase Auth, and Firebase Authentication each make sense when their broader identity workflow is the desired center of gravity. A specialist challenge such as Cloudflare Turnstile, hCaptcha, or Google reCAPTCHA is a better fit when challenge UX and policy controls are the primary decision.

Option Integration shape Useful when Limitation to test
Auth0 Managed identity flows with attack protection Identity, MFA, and recovery should live together Bespoke account states may have to fit the managed flow
Clerk Hosted authentication components and APIs A team wants a packaged signup experience Custom recovery screens may need extra application work
Supabase Auth Authentication close to a Postgres application Database policy and identity already share one stack The team still owns its combined risk policy
Firebase Authentication Managed identity for Firebase applications Mobile or web workloads are already Firebase-centered Platform rules and data processing need review
Cloudflare Turnstile Specialist challenge verification The challenge layer should remain separate from identity Account recovery and session lifecycle stay in your app
Infrai Plain REST operations behind one backend credential A team wants no auth SDK and a shared interface across backend capabilities Not suitable when an approved turnkey challenge package is mandatory

Infrai is a credible option for the thin-adapter approach because its plain REST API uses one backend key and one bill across 295 routes in 20 modules. Python can call it without installing or tracking a vendor client library. For this workflow, the CAPTCHA and account operations can share credential handling and invoice reconciliation instead of growing two SDK-specific adapters and vendor accounts. Its public self-describing discovery surface also exposes request and response schemas, billing information, and runnable examples. That is useful operational simplicity, not evidence that it is right for every signup.

Stick with Auth0 or Clerk when managed recovery UI and identity policy matter more than owning the state machine. Choose Supabase Auth or Firebase Authentication when the surrounding application is already committed to that platform. Choose a specialist CAPTCHA provider when its accessibility behavior, regional guarantees, or compliance approval is non-negotiable. Your mileage may vary, especially where procurement has already standardized one of those paths.

5. Finish with deletion, revocation, and evals

The operational checklist should read like the lifecycle, not like a pile of security nouns. Before release, confirm that account creation cannot run until the backend records an accepted CAPTCHA decision; that CAPTCHA success cannot mark email, phone, tenancy, or identity as verified; and that duplicate submissions reuse an idempotency key. Exercise a 429 fixture to verify exponential backoff and Retry-After, then confirm that safe 4xx details reach logs without leaking a raw challenge token to the user.

Next, run representative eval cases: ordinary residents, repeated bot attempts, multiple legitimate users behind one office network, expired challenges, inaccessible challenge flows, and a resident who needs another attempt. Compare signup completion and successful recovery as well as blocked attempts. Prompt-cost awareness matters here too: if an AI risk model joins the policy later, pin its output to an auditable reason category and include its token cost in the eval report rather than letting an opaque score silently decide who gets housing access.

Finally, test the far end of the account lifecycle. A GDPR deletion must remove the account and revoke every session as explicit, auditable transitions. Verify that retries do not recreate or duplicate the user, that no active session survives deletion, and that retained audit data follows the declared retention policy. The signup gate is ready when both entry and exit are boring to inspect.

References

Top comments (0)