How to Stop Fake Signups Without Hurting Real Users (With Code)
To stop fake signups without hurting real users, order your defences by how much they cost a genuine person: invisible checks first (honeypot, minimum fill time, rate limits), an invisible challenge second, email checks third, and email confirmation before the account receives anything valuable. Each layer is cheap to bypass alone; together they are not.
This post covers the order, the code, and the trade-off at each step. The code below was run on Node 22 on 1 October 2026 against Cloudflare's live verification endpoint using its published test keys.
First, decide what "fake" means for you
"Fake signup" covers four different problems, and they need different fixes. Mixing them up is how teams end up with a CAPTCHA that annoys customers while the actual abuse carries on.
| Type | What it looks like | What stops it |
|---|---|---|
| Scripted bots | Hundreds of signups, random names, fast submits | Honeypot, time-trap, rate limits, challenge |
| List bombing | Real victims' addresses submitted in bulk | Rate limits, challenge, confirm before sending more mail |
| Throwaway humans | Real person, disposable or alias address, wants a free trial | Disposable blocking, address normalisation, value gating |
| Mistyped real users | jane@gmial.com |
Typo suggestion. These are customers, not fraud |
OWASP lists bulk signup as automated threat OAT-019, Account Creation: "Bulk account creation, and sometimes profile population, by using the application's account sign-up processes."
List bombing deserves more attention than it gets, because it makes you the sender of abuse. Bots submit a victim's address to many forms at once so their inbox fills with confirmation emails, often to bury a fraud alert. M3AAWG's 2017 recommendation was that "all public subscription and web forms install one of the various types of CAPTCHA image or text challenges used to tell humans from automated sign-ups". It also proposed a Form-Sub header to mark form-triggered mail, but the IETF draft for it has expired and has no standing, so do not rely on receivers honouring it.
Layer 1: invisible checks that cost real users nothing
A honeypot and a signed minimum fill time stop most generic bots, and no human ever notices them. Start here.
Honeypot. Add a field that people cannot see or reach, and reject any submission where it has a value:
<div style="position:absolute;left:-10000px" aria-hidden="true">
<label for="website">Leave this empty</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
</div>
Hide it off-screen rather than with display:none, which some bots detect. aria-hidden and tabindex="-1" keep it away from screen reader and keyboard users. One trade-off: browser autofill occasionally fills a field with a common name like website. Log honeypot rejections for the first few weeks and check that none look human.
Signed time-trap. Put a timestamp in the form when you render it, sign it with a server secret, and reject submissions that come back too quickly or long after. Signing matters: an unsigned timestamp is just a field the bot can set.
Rate limits. Limit by IP and, separately, by email domain. The domain limit catches bots that rotate IPs but hammer one throwaway domain, and it is the one most implementations miss.
Layer 2: an invisible challenge, verified on the server
Use an invisible challenge such as Cloudflare Turnstile or reCAPTCHA v3, and always verify the token on your server. The widget on its own protects nothing.
Cloudflare's server-side validation docs say it directly: "You must call the Siteverify API to complete your Turnstile implementation. The client-side widget alone does not protect your forms." The details that matter in code:
| Cloudflare Turnstile | Google reCAPTCHA v3 | |
|---|---|---|
| Verify endpoint | challenges.cloudflare.com/turnstile/v0/siteverify |
www.google.com/recaptcha/api/siteverify |
| Token lifetime | 300 seconds | Two minutes |
| Reuse | Single use; a replay returns timeout-or-duplicate |
Single use; "can only be verified once" |
| Result | success true or false |
A score from 0.0 (likely bot) to 1.0 (likely good) |
| Suggested default | Pass/fail | "By default, you can use a threshold of 0.5" |
Sources: Turnstile validation, reCAPTCHA v3 and reCAPTCHA verification, all fetched 1 October 2026.
The reCAPTCHA token lifetime trips people up: Google advises calling execute "when the user takes the action rather than on page load", because a token generated on load is often stale by the time someone has typed their details.
Challenges are not free for users. The W3C's note on the inaccessibility of CAPTCHA concludes "there is still no single, ideal solution" and asks that any deployment be "closely monitored for effective performance". Invisible modes reduce the cost but do not remove it, so keep an interactive fallback and watch how often it triggers.
The code: layers 1 and 2 together
This module checks the honeypot, the signed time-trap, two rate limits and Turnstile, cheapest first, so that obvious bots never cost you a network call.
import { createHmac, timingSafeEqual } from "node:crypto";
const FORM_SECRET = process.env.FORM_SECRET ?? "change-me";
const MIN_FILL_MS = 3000;
const MAX_FILL_MS = 60 * 60 * 1000;
// 1. Signed time-trap: embed this in the form when you render it.
export function issueFormToken(now = Date.now()) {
const sig = createHmac("sha256", FORM_SECRET).update(String(now)).digest("hex");
return `${now}.${sig}`;
}
function checkFormToken(token, now = Date.now()) {
const [issued, sig] = String(token ?? "").split(".");
if (!issued || !sig) return "missing form token";
const expected = createHmac("sha256", FORM_SECRET).update(issued).digest("hex");
if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return "forged form token";
}
const elapsed = now - Number(issued);
if (elapsed < MIN_FILL_MS) return "submitted too fast";
if (elapsed > MAX_FILL_MS) return "form expired";
return null;
}
// 2. Sliding-window rate limit per key (use Redis in production).
const hits = new Map();
function rateLimited(key, limit, windowMs, now = Date.now()) {
const recent = (hits.get(key) ?? []).filter((t) => now - t < windowMs);
recent.push(now);
hits.set(key, recent);
return recent.length > limit;
}
// 3. Server-side Turnstile check. The widget alone protects nothing.
async function turnstileOk(token, ip) {
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET, response: token, remoteip: ip }),
signal: AbortSignal.timeout(5000),
});
const data = await res.json();
return data.success === true;
}
export async function screenSignup(form, ip, now = Date.now()) {
if (form.website) return { ok: false, reason: "honeypot filled" };
const tokenProblem = checkFormToken(form.formToken, now);
if (tokenProblem) return { ok: false, reason: tokenProblem };
if (rateLimited(`ip:${ip}`, 5, 10 * 60 * 1000, now)) return { ok: false, reason: "too many signups from this IP" };
const domain = String(form.email ?? "").split("@").pop().toLowerCase();
if (rateLimited(`domain:${domain}`, 20, 60 * 60 * 1000, now)) return { ok: false, reason: "unusual volume for this domain" };
try {
if (!(await turnstileOk(form["cf-turnstile-response"], ip))) return { ok: false, reason: "challenge failed" };
} catch {
return { ok: true, reason: "challenge service unreachable, allowed and flagged for review" };
}
return { ok: true, reason: "passed" };
}
We saved it as guard.mjs and ran a test script against it with Cloudflare's test secret keys (1x0000000000000000000000000000000AA always passes, 2x0000000000000000000000000000000AA always fails) and the dummy token XXXX.DUMMY.TOKEN.XXXX:
human: { ok: true, reason: 'passed' }
honeypot: { ok: false, reason: 'honeypot filled' }
too fast: { ok: false, reason: 'submitted too fast' }
forged: { ok: false, reason: 'forged form token' }
6th from one IP: { ok: false, reason: 'too many signups from this IP' }
With the always-fail key, the same human submission returned { ok: false, reason: 'challenge failed' }.
The thresholds (3 seconds, 5 per IP per 10 minutes, 20 per domain per hour) are starting points, not recommendations from any standard. Set them from your own traffic: an office or university behind one IP can legitimately produce several signups in a row, and a big customer onboarding a team will hit a per-domain limit. Exempt the large free-mail domains from the domain limit, or set it far higher for them.
Note the failure choice at the end: if Cloudflare is unreachable, the signup is allowed and flagged rather than refused. Whether to fail open or closed is a business decision. For a free newsletter, open is usually right. For anything with a payout or free compute, closed may be.
Layer 3: check the email address itself
Bots that get past layers 1 and 2, and humans who were never stopped by them, show up in the address. These checks are cheap and run server-side:
- Syntax, without an over-strict regex. Rejecting valid addresses is a self-inflicted fake-signup problem: see why email validation regex fails.
- Domain can receive mail. Reject domains that do not exist or publish a null MX.
- Disposable domains. A list plus an MX check catches most of them; we measured how well in blocking disposable emails on signup forms.
- Typos. Suggest a fix for
gmial.comrather than rejecting it: catching email typos at signup. - Duplicates in disguise.
jane+1@,jane+2@andj.a.n.e@gmail.comcan all be one inbox. Normalise before checking uniqueness: plus addressing and free-trial abuse.
If you need to know whether the mailbox itself exists, that requires an SMTP check rather than DNS. A separate post on real-time verification covers the latency budget for doing that inline.
Layer 4: make the account worthless until the address is confirmed
The strongest defence against fake accounts is that a fake account gets nothing. Require the user to click a confirmation link before granting the things worth abusing: free credits, API keys, invitations to other people, outbound messaging.
This also limits list bombing. If you send one confirmation and nothing more until it is clicked, an attacker can make you send one email to a victim, not a stream of them. Pair it with the rate limits above so the one email cannot be triggered a thousand times. The difference between confirming an address and verifying one is covered in double opt-in vs email verification.
Let users into the product before confirming if you like. Gate the value, not the login.
What to watch after launch
You will not know whether a layer is hurting real users unless you log every rejection with its reason. Review weekly at first:
| Signal | What it suggests |
|---|---|
| Honeypot rejections with realistic names and company domains | Autofill is filling the honeypot. Rename the field |
| "Too fast" rejections clustered at 3-4 seconds | Password managers filling the form. Lower the minimum |
| Challenge failure rate climbing | Bot pressure, or a broken widget on one browser |
| Signups that never confirm, from one domain | Throwaway or list-bombing traffic on that domain |
| Confirmation emails bouncing | Fake or mistyped addresses getting through. Check your bounce rate. |
That last row is the one that touches your email reputation. Every confirmation sent to a non-existent address is a hard bounce against your sending domain, which is why address checks belong before the first email, not after.
The short version
- Honeypot, signed time-trap and rate limits (per IP and per domain): invisible, cheap, run first.
- An invisible challenge, always verified server-side, with an accessible fallback.
- Address checks: syntax, MX, disposable, typo suggestion, normalisation.
- Confirm the address before the account gets anything worth abusing.
- Log every rejection with a reason, and loosen any rule that is catching people.
You can test a single address against the checks in layer 3 with our free email verifier.
Common questions
What is the most effective way to stop fake signups?
Layering. No single check stops everything, so combine invisible checks (a honeypot field and a minimum fill time), rate limits per IP and per email domain, an invisible challenge such as Turnstile or reCAPTCHA verified on the server, and email confirmation before the account gets anything of value.
Do honeypot fields still work against bots?
Against generic form-filling bots, yes, and they cost real users nothing. Bots written for your specific site will learn to skip the field, which is why a honeypot should be one layer among several rather than the whole defence.
Is a CAPTCHA enough to stop fake accounts?
No. A challenge raises the cost of automation, but it does not stop a person signing up with a throwaway address, and it can be solved by paid services. It also adds friction and accessibility problems, which the W3C has documented, so it belongs in the middle of a layered approach.
Why do attackers sign up with other people's email addresses?
Sometimes to abuse a free tier, but also to use your confirmation emails as a weapon. Bots submit a victim's address to thousands of forms at once to bury their inbox, a pattern known as list bombing or subscription bombing, and your domain sends the flood.
Verify unlimited addresses for $29.99/month
Real SMTP mailbox checks. No credits, no per-email fees.
Get Started