Signup protection recipe
Express middleware that blocks fake signups
Add one middleware in front of your sign-up route. It reads req.body.email, checks it with SimpleVerifier, answers 422 with a clear message for disposable, invalid and misspelled addresses, and puts the verdict on res.locals so the route can store role and catch-all flags with the new account.
- Where it runs
- Runs before your route handler creates the account.
- Timeout
- The middleware uses 8 seconds and then lets the request through.
You need an API key from the API keys page (Pro, Business or Agency), stored server-side as SIMPLEVERIFIER_KEY. Reviewed 2026-10-31.
Block, warn or allow
- Block: disposable or temporary inbox (reason_code "disposable" (action "do_not_send"))
- Block: invalid: bad format, no mail server, mailbox not found (action "do_not_send" (reason_code "syntax", "no_mx", "mailbox_not_found", "domain_not_hosted"))
- Block: misspelled domain (gmial.com, hotmial.com) (reason_code "typo_domain" (action "do_not_send"))
- Warn: role inbox (info@, sales@, admin@) (reason_code "role")
- Warn: catch-all domain (reason_code "catch_all")
- Warn: looks invented (test@, john.doe@, example.com) (likely_invented true)
- Allow: valid, or the check could not finish (action "send", "send_with_care", "retry_later", or any non-2xx answer or timeout)
The reasoning for each row is on the signup protection overview.
1. Add the guard helper
One file, no dependencies, never throws. It blocks only what the table above says to block and lets every unfinished check through.
signup-guard.ts // signup-guard.ts: server code only. Needs fetch and AbortSignal.timeout (Node 18+, Deno, Workers, Bun). export type Decision = "block" | "warn" | "allow"; export interface Verdict { decision: Decision; /** Stable SimpleVerifier reason_code, or null when the check didn't finish. */ reasonCode: string | null; /** Message to show the person when decision is "block". */ message: string | null; } interface Options { apiKey?: string; /** Keep it under your platform's hook limit. A timeout lets the signup through. */ timeoutMs?: number; /** Log what would be blocked, block nothing. Run this way for a few days first. */ dryRun?: boolean; } const API_URL = "https://simpleverifier.com/api/v1/verify"; const WARN_CODES = new Set(["role", "catch_all"]); const ALLOW: Verdict = { decision: "allow", reasonCode: null, message: null }; function blockMessage(reasonCode: string, reason: string): string { if (reasonCode === "disposable") return "Please use a permanent email address, not a temporary inbox."; const hint = reason.match(/did you mean ([^?\s]+)\??/i)?.[1]; if (hint) return `Check the spelling of your email address. Did you mean ${hint}?`; return "This email address can't receive mail. Please check it."; } export async function checkSignupEmail(email: string, options: Options = {}): Promise<Verdict> { const { apiKey = process.env.SIMPLEVERIFIER_KEY, timeoutMs = 8000, dryRun = false } = options; if (!apiKey) return ALLOW; let result: { action?: string; reason?: string; reason_code?: string; likely_invented?: boolean }; try { const res = await fetch(API_URL, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, body: JSON.stringify({ email: email.trim() }), signal: AbortSignal.timeout(timeoutMs), }); if (!res.ok) return ALLOW; // 401, 402, 429, 503: fail open result = await res.json(); } catch { return ALLOW; // timeout or network error: fail open } const reasonCode = result.reason_code ?? null; if (result.action === "do_not_send") { const verdict: Verdict = { decision: "block", reasonCode, message: blockMessage(reasonCode ?? "", result.reason ?? "") }; if (!dryRun) return verdict; console.info("[signup-guard] dry run, would block", { reasonCode }); return { ...verdict, decision: "warn" }; } if (result.likely_invented || (reasonCode && WARN_CODES.has(reasonCode))) { return { decision: "warn", reasonCode, message: null }; } return { decision: "allow", reasonCode, message: null }; }2. Add the middleware
Mount it only on routes that create accounts or hand out credits, after the body parser.
middleware/blockFakeSignups.ts import type { NextFunction, Request, Response } from "express"; import { checkSignupEmail } from "../signup-guard"; export async function blockFakeSignups(req: Request, res: Response, next: NextFunction) { const email = typeof req.body?.email === "string" ? req.body.email : ""; if (!email) return next(); // let your own validation answer const verdict = await checkSignupEmail(email, { dryRun: process.env.SIGNUP_GUARD_DRY_RUN === "1" }); if (verdict.decision === "block") { return res.status(422).json({ error: verdict.message, field: "email" }); } res.locals.emailCheck = verdict; // { decision: "warn" | "allow", reasonCode } return next(); }3. Use it on the sign-up route
The handler can read res.locals.emailCheck to hold free credits for warn accounts.
server.ts import express from "express"; import { blockFakeSignups } from "./middleware/blockFakeSignups"; const app = express(); app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.post("/signup", blockFakeSignups, async (req, res) => { const { decision, reasonCode } = res.locals.emailCheck ?? { decision: "allow", reasonCode: null }; // await createUser({ email: req.body.email, emailCheck: decision, emailReason: reasonCode }); res.status(201).json({ ok: true }); }); app.listen(3000);
More on calling the API from this stack (retries, status handling, tests): API guide. Every field is described in the API docs.
Common questions
- Does the middleware ever throw?
- No. checkSignupEmail catches network errors and timeouts and returns allow, so Express never sees an unhandled rejection from it.
- Should I rate-limit the sign-up route as well?
- Yes. Email checks stop throwaway and invented addresses; a rate limit per IP stops someone hammering the route with many real-looking ones. They work best together.
- Which plan includes the API?
- Pro ($29.99/month, 10,000 calls a month included), Business and Agency. Each address checked is one call. Starter and Growth have no API access.
- How do I try it without blocking anyone?
- Set SIGNUP_GUARD_DRY_RUN=1. The code logs what it would block and lets everyone through. Read the logs for a few days, then remove the variable.
Other recipes
- Block fake and disposable signups in Supabase Auth
- Block fake and disposable signups in Clerk
- Block fake and disposable signups in Firebase Auth
- Block fake and disposable signups in Next.js
- Block fake signups at the edge with a Cloudflare Worker
- Filter fake form signups in Zapier or n8n
- Filter fake Webflow and Framer form signups