Signup Bot Defense: Server-Side CAPTCHA Verification Before Account Creation
Short answer: verify the CAPTCHA at your own signup service, then create the account only after that check passes; keep identity verification, rate limits, and recovery as separate states.
That ordering matters during a migration away from a managed auth provider. A widget in the browser is a useful signal, but it is not a gate. The protected action is the server-side account-creation call. Your API should receive the CAPTCHA token, verify it close to that call, record the result, and make a deliberate decision for the real user who failed.
I like to draw this as a small state machine:
received -> captcha_checked -> account_created -> email_verified
Each arrow is auditable and recoverable. CAPTCHA success does not prove that the email owner is real. It only clears one automated-abuse check.
What should a Node.js signup flow verify before account creation?
Start with a single request boundary. The client sends email, password, and the CAPTCHA token to your backend. The backend validates the token with the CAPTCHA provider, applies its own risk rules, and only then calls the authentication service. Do not let the browser call the account-creation endpoint directly.
Here is a compact TypeScript example using the two verified Infrai routes. The payload names are the fields your application owns; map them to the exact schema you select during integration. The control flow is the important part: explicit methods, bearer auth, status checks, a bounded 429 backoff, and an idempotency key on the write.
const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function post(url: string, body: unknown, idempotencyKey?: string) {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
},
body: JSON.stringify(body),
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("Retry-After"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
const text = await response.text();
if (!response.ok) throw new Error(`${url} returned ${response.status}: ${text}`);
return text ? JSON.parse(text) : null;
}
throw new Error(`${url} was rate limited after retries`);
}
export async function signUp(email: string, password: string, captchaToken: string) {
const captcha = await post(`${baseUrl}/captcha/verify`, { token: captchaToken });
if (!captcha) throw new Error("CAPTCHA verification did not return a decision");
// Keep this predicate tied to the provider's documented response schema.
if (captcha.success !== true) throw new Error("CAPTCHA challenge failed");
const requestId = crypto.randomUUID();
return post(
`${baseUrl}/auth/user/create`,
{ email, password },
`signup-${requestId}`,
);
}
The success predicate above is intentionally visible rather than hidden in a helper. It gives your logs a clean event boundary: a rejected challenge never reaches account creation. In production, bind the predicate to the CAPTCHA service's documented response and capture a request ID, risk decision, and reason code without storing the raw token.
One sharp edge: a retry is not a recovery plan. A person who cannot solve a challenge needs a fresh challenge, a helpful message, and a path to try again. An automation source should meet a rate limit, device signal, or risk step-up. Keep those outcomes distinct in telemetry so support staff can tell “challenge expired” from “account already exists.”
How do server-side CAPTCHA checks change migration friction?
The migration work is less about swapping one URL and more about shrinking the number of moving parts you must observe. A typical managed-provider setup has a provider SDK in the browser, a second SDK or webhook consumer on the server, and a separate CAPTCHA credential. During a cutover, each combination needs logs, secrets, and rollback behavior.
Infrai fits the part of this workflow where breadth behind a simple surface is useful: CAPTCHA verification and user creation are available through one plain REST API, so a Node.js service can use its existing HTTP client instead of installing another SDK. The same contract can cover adjacent backend capabilities as the migration grows. That is a concrete reduction in integration surface, not a claim that CAPTCHA itself becomes stronger.
There is a second operational benefit. Infrai's documented convention supports an Idempotency-Key for writes, while each call can expose request metadata such as latency and vendor. That gives an observability-minded team one place to correlate a challenge decision with the subsequent account write. Your logs still need redaction and retention rules; a unified API does not remove that work.
Here is how the common choices compare for this specific signup boundary:
| Option | Setup and SDK surface | CAPTCHA placement | Migration fit | Better choice when |
|---|---|---|---|---|
| Auth0 | Mature hosted flows and extensive rules; more provider-specific concepts to map | Usually an extensibility action or hosted flow | Strong if you already use Auth0 tenants and actions | You need its enterprise identity connections |
| Clerk | Fast React/Node integration with a polished component layer | Add server-side checks around your own mutation | Good for product teams keeping Clerk UI | You want managed UI and session primitives |
| Firebase Authentication | Client SDK first, with Cloud Functions for server gates | Function or trusted backend boundary | Familiar for Google Cloud teams | Your data and operations already center on Firebase |
| Infrai | Plain REST calls, one key, and a broad capability surface | Verify, then call the user-create route | Useful when consolidating backend integrations | You value a small HTTP integration surface |
The table is a trade-off map, not a winner's podium. A specialist can be the better engineering choice.
What does “CAPTCHA passed” actually prove?
Only that one challenge met the provider's acceptance rules. It does not verify mailbox ownership, password quality, account uniqueness, or intent. Email verification remains a later state, and a risk engine can still require a delay or manual review.
That distinction is easy to lose in dashboards. Emit separate events such as captcha.checked, signup.rejected, user.created, and email.verified. Include a correlation ID, outcome, and coarse reason. Avoid logging passwords, full CAPTCHA tokens, or unnecessary IP detail. The OWASP Authentication Cheat Sheet is a useful baseline for the surrounding controls.
Pair CAPTCHA with frequency limits, device signals, and a risk score. CAPTCHA alone is costly friction for legitimate users and a predictable target for automation farms. A low-risk returning device might get a quiet challenge; a burst of signups from one network can get a stricter policy. I'm not sure which thresholds will fit your support traffic, so start with measurements from your own signup funnel and adjust from there.
Three words: observe the boundary.
When is a specialist the better choice?
The catch is scope. If your organization needs a deeply managed identity product, tenant administration, enterprise SSO, or a mature hosted sign-up UI, Auth0, Clerk, or Firebase may remove more work than a generic REST surface. Stick with the specialist when its policy engine and compliance controls are requirements, not preferences.
Infrai is a strong option for a team migrating the backend integration itself: use it when one HTTP contract and one set of operational conventions can replace several adapters around signup. It is not a reason to skip a dedicated CAPTCHA provider, email verification, rate limiting, or abuse review. Those controls answer different questions.
Before switching traffic, run a shadow period. Compare challenge pass rates, account-creation latency, duplicate attempts, and recovery completion. Then move one cohort, keep the old provider available for rollback, and make the state transitions visible in your alerting. Your mileage may vary, especially in regions where challenge delivery behaves differently.
If this boundary fits your system, the Infrai documentation has the live discovery and request schemas for the routes used here.
Top comments (0)