How to block fake signups in Express
To block fake signups in an Express app, check the email, phone and IP of every new signup in your signup route, right before you create the user. With SignupVet that is one API call, no new dependencies, and about ten minutes of work.
Before you start
- Create a free account at signupvet.com/signup (250 checks a month, no card) and confirm your email.
- Create an API key in the dashboard.
- Add it to your server environment, never to browser code:
SIGNUP_API_KEY=sv_live_...
1. Add a small helper
This helper calls the API with a 2 second timeout and fails open: if the API is slow, cannot be reached or your monthly checks are used up, it returns allow, so your signup route never breaks. It uses the built-in fetch of Node 18 and later.
// signup-check.js: check a signup with SignupVet (Node 18+, no dependencies).
//
// Fails open: if the API is slow, unreachable or returns an error (for example
// because your monthly checks are used up), the signup is allowed. Your signup
// form never breaks because of this check.
const FAIL_OPEN = Object.freeze({ recommendation: "allow", risk_score: null, reason_codes: [], failed_open: true });
/**
* @param {{ email?: string, phone?: string, ip?: string, countryHint?: string,
* userAgent?: string, timezone?: string, acceptLanguage?: string }} signup
* @returns {Promise<{ recommendation: "allow" | "review" | "block", risk_score: number | null,
* reason_codes: string[], failed_open?: boolean }>}
*/
export async function checkSignup(signup, { timeoutMs = 2000 } = {}) {
const body = {
email: signup.email,
phone: signup.phone,
ip: signup.ip,
country_hint: signup.countryHint,
context: {
user_agent: signup.userAgent,
timezone: signup.timezone,
accept_language: signup.acceptLanguage,
},
};
const baseUrl = process.env.SIGNUPVET_URL ?? "https://www.signupvet.com";
try {
const res = await fetch(`${baseUrl}/score`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SIGNUP_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) {
console.warn(`SignupVet returned ${res.status}, allowing signup`);
return FAIL_OPEN;
}
return await res.json();
} catch (err) {
console.warn(`SignupVet unreachable (${err?.name ?? "error"}), allowing signup`);
return FAIL_OPEN;
}
}
2. Call it in your signup route
// Express signup route with a SignupVet check before the account is created.
// Run: npm install && SIGNUP_API_KEY=sv_live_... npm start
import express from "express";
import { checkSignup } from "../node/signup-check.js";
const app = express();
app.use(express.json());
// Behind a proxy or load balancer (Heroku, Render, Railway, Nginx ...), so req.ip is the visitor's IP.
app.set("trust proxy", 1);
app.post("/signup", async (req, res) => {
const { email, password, phone, timezone } = req.body ?? {};
const check = await checkSignup({
email,
phone,
ip: req.ip,
userAgent: req.get("user-agent"),
acceptLanguage: req.get("accept-language"),
timezone, // optional: Intl.DateTimeFormat().resolvedOptions().timeZone from the browser
});
if (check.recommendation === "block") {
// Keep the message neutral; do not tell bots which signal tripped.
return res.status(400).json({ error: "We could not create your account. Please use a different email address." });
}
const user = await createUser({ email, password });
if (check.recommendation === "review") {
// For example: hold free credits until the email is confirmed, or queue for a manual look.
await flagForReview(user.id, check.reason_codes);
}
res.json({ ok: true });
});
// Replace these with your own user store.
async function createUser({ email }) {
return { id: crypto.randomUUID(), email };
}
async function flagForReview(userId, reasons) {
console.log(`review user ${userId}:`, reasons.join(", "));
}
app.listen(3000, () => console.log("Listening on http://localhost:3000"));
Three things matter here:
- Check before you create the user. On
blockthe account is never created, so no free credits or trial are handed out. - Send the visitor's IP, not your proxy's. Behind a load balancer or a platform like Heroku, Render, Railway or Nginx,
req.ipis the proxy's address unless you settrust proxy. Use1for one proxy in front of your app; set it to the number of proxies you actually have, because trusting more than that lets clients fake their IP. - Keep the error neutral. The message does not say which signal tripped, so a real user knows what to do and a script learns nothing.
3. Decide what each result means for you
| recommendation | What to do |
|---|---|
allow | Create the account as usual. |
review | Create the account, but hold back free credits or the trial until the email is confirmed, or look at it manually. |
block | Do not create the account. Show a neutral message. |
For review, a common middle path is to create the account but unlock free credits only after the email is confirmed. The reason_codes tell you why, for example disposable_domain or signup_velocity_ip. All codes are listed in the documentation.
Good to know
- Express 4 and 5 both work. The helper never throws, so the route does not need its own try/catch for the check.
- Optional: send the browser time zone as
timezonefrom your signup form. A mismatch with the IP's country is a useful extra signal. - Combine it with a rate limit on the signup route, for example with the
express-rate-limitpackage. A rate limit stops floods from one address; SignupVet also catches throwaway inboxes, datacenter IPs and waves spread across a whole network. - Using an auth provider instead of your own route? See the guides for Better Auth, Supabase, Clerk and Firebase.
Test it
Send a signup with test123@mailinator.com: the route answers 400 with your neutral message and no user is created. A normal address goes through with allow.
The full example is on GitHub: github.com/signupvet/examples. For the bigger picture, read how to stop fake signups.
Add it to your signup today
SignupVet checks the email, phone and IP of every new signup in one call and spots waves of signups from the same network. Free for 250 signups a month, no card needed.