DEV Community

RivenPulse5812
RivenPulse5812

Posted on

Why I Chose 2 Gates for Signup CAPTCHA Verification Before Account Creation: Audit Lessons

Short answer: for signup bot defense, run server-side CAPTCHA verification before account creation, then carry that decision into the courier's password-reset audit record. The useful unit is not “a passed widget.” It is a traceable identity handoff, and I don't trust a browser-only green tick.

Control What it proves Friction
Server-side challenge check The submitted token met the provider's rules at signup time One network round trip
Identity and recovery linkage A later reset belongs to the same accepted signup event More durable audit data
Invitation or review path A human can resolve a false positive Queue time and staff effort

I chose the two-gate pattern for a logistics service: challenge verification first, recovery eligibility second. It keeps synthetic courier accounts out of the reset queue while leaving an explainable path for legitimate drivers. It also gives an auditor a bounded question to answer: which policy allowed this identity to exist?

How should signup bot defense use server-side CAPTCHA verification?

A browser challenge is a hint until the server verifies it. JavaScript can be automated, altered, or replayed. The server must submit the token to the challenge service, validate the documented response fields, and only then create the durable account record. OWASP's Authentication Cheat Sheet makes the same trust-boundary point in broader terms: automated abuse belongs in the application security design.

The ordering is the whole trick. Parse and validate the request. Check the token from the server network. Bind the result to the intended action and host when the provider exposes those fields. Create the courier identity in the same controlled flow. If any step fails, return a generic public error and put the precise reason in a protected event log.

I once inherited a flow that inserted a user row and queued CAPTCHA verification for later. It passed the happy-path demo. Four hours later, the password-reset worker was processing thousands of identities that had never cleared the gate. The queue contained duplicate emails, incomplete carrier profiles, and reset notifications sent to addresses that had never passed a challenge; tracing one record required joining three services whose clocks disagreed by nearly a minute. The fix was boring: move the write after verification, issue one attempt ID at the edge, and make the audit event part of the transaction boundary. That small change also gave support staff a single correlation value to quote when a driver called about a rejected signup.

Boring wins.

type Verification =
  | { ok: true; checkedAt: string; policy: string }
  | { ok: false; reason: "missing" | "expired" | "action_mismatch" | "provider_unavailable" };

async function checkChallenge(token: string, ip: string): Promise<Verification> {
  if (!token) return { ok: false, reason: "missing" };

  const response = await fetch("https://captcha.example/verify", {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      secret: process.env.CAPTCHA_SECRET!,
      response: token,
      remoteip: ip
    })
  });

  if (!response.ok) return { ok: false, reason: "provider_unavailable" };
  const result = await response.json() as {
    success: boolean;
    action?: string;
    hostname?: string;
    challenge_ts?: string;
  };

  if (!result.success) return { ok: false, reason: "expired" };
  if (result.action !== "courier_signup") return { ok: false, reason: "action_mismatch" };
  return { ok: true, checkedAt: new Date().toISOString(), policy: "signup-v2" };
}

async function registerCourier(input: { email: string; password: string; token: string }, ip: string) {
  const verification = await checkChallenge(input.token, ip);
  const attemptId = crypto.randomUUID();

  await audit.append({
    attemptId,
    event: "courier_signup_challenge",
    outcome: verification.ok ? "accepted" : verification.reason,
    policy: verification.ok ? verification.policy : "signup-v2",
    ipHash: hashForRetention(ip),
    at: new Date().toISOString()
  });

  if (!verification.ok) throw new Error("signup_not_accepted");
  return accounts.insert({ email: input.email, password: input.password, attemptId });
}
Enter fullscreen mode Exit fullscreen mode

The endpoint is intentionally provider-neutral. Response names vary, so the contract test should pin the current provider specification rather than guessing fields. I am not sure every challenge family offers identical replay guarantees; your mileage may vary. The invariant is stronger than any vendor detail: no account row exists before a server-side decision, and the accepted attempt ID travels with the identity.

What should the audit record prove about a reset?

Password recovery is where signup evidence either earns its keep or disappears. Store an opaque attempt ID on the account, then require the reset service to join its event to that ID. An auditor should be able to reconstruct the chain without seeing a CAPTCHA secret:

  1. The request arrived for a tenant, carrier, and action named courier_signup.
  2. The verifier returned an accepted decision at a specific time and policy version.
  3. The account was created from that accepted attempt.
  4. A reset request later applied the same rate limits, generic responses, and notification policy.

Do not retain the raw token. Treat it as a credential. Hash or truncate network identifiers according to the retention policy, restrict log access, and protect event integrity. Keep clocks synchronized; an audit timeline with drifting timestamps is surprisingly hard to defend.

The reset endpoint must not reveal whether an email exists. “Token invalid” and “address unknown” should look identical outside the security log. Rate-limit by address, network range, and device signal independently. Record policy version, outcome, and correlation ID for both accepted and rejected resets. This is less glamorous than a dashboard, but it answers the question reviewers actually ask.

Where does security friction become an operations problem?

Measure the trade-off instead of arguing from intuition. Track challenge completion, verification latency, legitimate rejection rate, duplicate submissions, and reset abuse per 1,000 new identities. Segment by managed depot tablets, residential mobile networks, and invitation status; one threshold will not fit all three.

Three seconds is noticeable. So is a support queue.

An invitation can lower friction, but it should not silently bypass server verification unless your threat model explicitly accepts that assurance level. A review path should have an owner, a service-level target, and an event that explains who approved the identity. Otherwise “manual exception” becomes an unbounded hole in the audit story.

The catch is accessibility. Shared devices, script blockers, and poor connectivity are normal in delivery operations. Offer a staffed or signed-invitation route, then apply equivalent recovery controls after approval. If a challenge blocks a real courier while reset remains permissive, the abuse has merely moved.

When is a different control the better choice?

CAPTCHA is not suitable when policy forbids sending challenge data to an external verifier, when the service is private behind carrier invitations, or when clients cannot reliably execute the challenge. Use signed invitations, device-tuned proof of work, or a staffed approval queue in those cases, and document the assurance each one provides.

Stick with a silent risk signal when measured bot volume is low and a visible challenge materially hurts completion. Choose manual review when every identity maps to a contractual carrier relationship and a false positive is expensive. The runner-up is better when identity fraud, rather than high-volume automation, is the dominant threat.

There is no universal “best CAPTCHA.” There is a defensible sequence: verify on the server, create once, link the evidence, and make recovery tell the same story.

References

Top comments (0)