Build a Verified Waitlist That Resists Email Spam
A naïve waitlist is an unauthenticated email-sending API. Anyone can submit someone else's address, trigger branded messages, fill your database with fake users, and burn through delivery limits. The form looks harmless; the abuse path is obvious once it is public.
NamoID hit this problem while opening access. The fix was not one CAPTCHA. It was a state-machine change: verify control of the email before creating the request, keep applicants separate from users, and apply abuse controls at every transition.
The safe waitlist state machine
email entered
|
v
human + rate-limit checks
|
v
short-lived email challenge ------> expires / attempts exhausted
|
v
email control verified
|
v
pending waitlist entry (not a user)
| |
approve reject
| |
access granted cooldown, then may reapplyThe critical invariant is simple: an email address does not become a waitlist record until its owner proves control of it.
Threat-model the form as an email API
Assume an attacker can call the endpoint without loading your page. Then enumerate what costs money, reveals state, or creates durable data.
| Attack | Naïve result | Required control |
|---|---|---|
| Submit a victim's address | Victim receives unsolicited branded email and becomes a lead | Ownership challenge before persistence |
| Rotate thousands of addresses | Email bill and database grow | Per-IP and environment-wide budgets |
| Distribute across many IPs | Per-IP limits are bypassed | Per-target limits, bot controls, aggregate circuit breaker |
| Guess OTPs | Address can be falsely verified | Short expiry, bounded attempts, one active code |
| Resend repeatedly | Multiple valid codes and message flooding | Resend cooldown and old-code invalidation |
| Probe known addresses | Account or waitlist status leaks | Equivalent responses and timing |
| Race duplicate submissions | Duplicate applicants and emails | Unique constraint, idempotent enqueue, transactional handling |
The waitlist has at least two scarce resources: email delivery and reviewer attention. Protect both. A CAPTCHA may reduce automated sends, but it does not stop a human from submitting someone else's address or a distributed attacker from staying below one IP threshold.
Verify ownership before creating the request
Send a short-lived, single-use code. Store only a hash of the code, scope it to the environment and normalized email, cap attempts, and delete it after successful verification.
Resending must invalidate the previous code. Otherwise multiple valid codes extend the attack window and confuse users.
The verification email should say exactly what it does: it confirms a waitlist request, not an account, subscription, or marketing consent. If the recipient did not initiate it, they can ignore it without any account having been created.
Model the challenge as ephemeral state rather than a database applicant:
key: waitlist_challenge:{environment}:{email_hash}
value: code_hash, expires_at, attempts
TTL: challenge lifetimeIssue replaces the current value. Verify compares the submitted code in constant time, increments bounded attempts, checks expiry, and consumes the key after success. Scope by environment so a code from Test cannot verify the same address in Live.
Do not put the email, plaintext code, or internal challenge key into analytics. Delivery logs should identify the template and outcome without retaining the authentication factor.
Do not create a user yet
A pending applicant is not an authenticated application user. Mixing the two creates misleading user metrics, unnecessary personal-data retention, and awkward deletion semantics.
Use a separate waitlist record:
environment_id
normalized_email
status: pending | approved | rejected
applied_at
decided_at
decided_byCreate the actual user only when the approved person completes authentication. This keeps the user list meaningful and avoids granting sessions or password/passkey options during the application flow.
NamoID's behavior tests enforce this invariant: verifying a new waitlist challenge creates a pending WaitlistEntry and no User row.
Use a unique constraint on (environment_id, normalized_email). The service should return the existing pending or approved row for a repeated request instead of creating duplicates. If two successful verifications race, catch the uniqueness conflict and load the winning row.
Idempotency matters beyond neat data. Without it, two tabs can trigger duplicate confirmation messages, reviewer decisions can target different rows, and a later approval may unlock only one of several records.
CAPTCHA is one layer, not the design
Bot challenges raise the cost of automation but do not prove ownership of an email address. Apply a managed challenge before sending a waitlist code, then still require the email code.
Treat challenge failure as a normal recoverable UI state. Never render provider error payloads directly in the browser, and do not disclose internal risk scores or rule thresholds.
Choose fail-open or fail-closed per dependency. If the public waitlist's rate-limit store is unavailable, failing closed protects email capacity and recipients at the cost of temporary applications. For a low-stakes marketing form that sends no email, a product might choose differently. Document the decision and monitor it; do not let a Redis exception accidentally disable every control.
Rate-limit two dimensions
One IP-only limit lets an attacker distribute requests across addresses. One email-only limit lets an attacker rotate addresses from one machine. Use independent buckets:
IP bucket: requests from this network
identity bucket: requests for this normalized email
pair bucket: optional IP + email correlationOWASP's bot and anti-automation guidance similarly warns that one paired bucket can let a single IP attack unlimited identifiers. Apply feature-specific limits in addition to edge-wide limits.
Return a generic, human message such as “We couldn't continue right now. Please try again later.” Do not expose observed counts, exact thresholds, Redis scopes, or internal retry calculations in a public HTML flow.
Apply an aggregate environment budget even when every individual request looks acceptable. This is the cost circuit breaker: if a botnet distributes requests across thousands of IPs and addresses, the environment-wide ceiling still stops delivery. Alert before the hard ceiling so an operator can distinguish a campaign spike from abuse.
Hash rate-limit identifiers with a server-controlled construction when the raw IP or email is unnecessary. Expire counters and quarantine markers. A permanent database of every address that touched the form defeats the minimisation gained elsewhere.
Prevent account enumeration
Known and unknown emails should produce structurally equivalent responses. Differences in text, status code, timing, redirect, or available sign-in methods can reveal whether an account exists.
NamoID's identifier-first tests normalize the echoed email, flow token, and local test OTP, then require the resulting known-user and unknown-user pages to match. That kind of behavioral assertion catches enumeration leaks that unit tests around one service function miss.
The OWASP Authentication Cheat Sheet recommends generic responses and warns that even different HTTP status codes can become a discrepancy factor.
Test the complete response shape, not just copy. Compare status, template, redirect count, form fields, method buttons, timing class, and client-visible errors. Local-development conveniences such as displaying an OTP must be excluded from production and normalised in equivalence tests.
Hide authentication methods during waitlist verification
When a new applicant is confirming a waitlist request, do not show password, passkey, phone OTP, or “more sign-in options.” Those methods imply an account exists and can create broken paths around approval.
The page should do one job:
- Explain that the code confirms the request.
- Accept the code.
- Show a neutral success state.
- Offer a safe next step, such as reading product guides or returning to the application.
After approval, the normal authentication methods can become available.
This rule should be backend-owned. Hiding password with CSS while the password endpoint still accepts a pending applicant creates a bypass. The environment's access decision must gate user creation and session issuance at the service layer.
Design rejection as a state, not deletion
Deleting a rejected record immediately lets the same address resubmit in a tight loop and erases decision history. Keep the rejected state and apply a defined cooldown. After the cooldown, reuse the record by moving it back to pending with a new application time.
That gives operators predictable behavior without creating duplicate rows. Every decision should record the actor and time in the audit trail.
Do not reveal whether an address was rejected to arbitrary callers. The applicant-facing message can remain generic while the authenticated console shows the real state to authorized team members.
Keep the decision transition narrow: only pending -> approved or pending -> rejected. Do not silently flip an approved row to rejected, or vice versa, because earlier notifications and audit events have already occurred. If the business needs revocation after approval, model it as a separate access-control event with its own user-facing consequences.
When the cooldown expires, re-open the same stable row only after a fresh ownership challenge. Reset the application and decision timestamps, clear the previous decision actor from current state, and append a reapplied event. Preserve history in the event log rather than multiplying applicant rows.
Send lifecycle email, not marketing email
A verified waitlist generally needs three transactional messages:
- Request received: confirms the verified application is pending.
- Access granted: explains how to continue after approval.
- Welcome: sent when the person actually creates or enters the account, if useful.
Each message should identify the application, explain why it was sent, include support and privacy links, and state what to do if the recipient did not initiate the action. Do not silently turn a waitlist application into a marketing newsletter subscription.
Configure a monitored Reply-To, authenticate the sending domain, and process bounces and complaints. Repeatedly sending to a bouncing address harms reputation and can hide abuse behind delivery retries. The approval message should link to a safe application entry point, not embed a long-lived bearer token.
Minimize data for DPDP readiness
An email address is personal data. Collect only what is needed to operate the waitlist, state the purpose clearly, restrict console access, define retention for rejected and abandoned applications, and support deletion.
India's notified Digital Personal Data Protection Rules, 2025 require clear notices describing the personal data and purpose. A verified request supports accuracy and abuse prevention, but it does not eliminate notice, security, retention, or rights obligations.
Verification is not marketing consent. It proves that the person controls the address and completed the stated request. Keep newsletter choice separate and easy to withdraw. Define retention for unverified challenges, pending applications, approved-but-unused invitations, rejected applications, and abuse counters instead of applying one indefinite period.
This is general engineering information, not legal advice. Review your waitlist notice, processing basis, retention, processor contracts, and rights workflow with qualified counsel.
Instrument abuse without logging secrets
You need enough telemetry to see attacks and debug false positives. Record structured security events such as:
{
"event": "waitlist.rate_limited",
"environment_id": "env_...",
"ip_hash": "sha256:...",
"reason": "environment_budget",
"occurred_at": "2026-07-23T08:12:00Z"
}Useful counters include challenge requests, successful verifications, expired challenges, incorrect attempts, CAPTCHA failures, rate-limit decisions, environment-budget use, quarantine events, pending applications, approvals, rejections, and reapplications.
Do not include plaintext OTPs, passwords, session tokens, full email addresses, raw form bodies, or provider error payloads. Restrict detailed security telemetry to operators; the public page gets a safe, actionable message.
Alert on ratios, not just volume. A sudden fall in verification success may indicate email-delivery trouble. A rise in sends without pending applications may indicate challenge abuse. A single environment consuming most of the global email quota may need an automatic circuit breaker.
Test behavior end to end
Service unit tests cannot prove the browser flow is safe. Add behavior tests across the public page, backend state, email adapter, and console.
| Scenario | Expected result |
|---|---|
| New address entered | One ownership email; no waitlist row; no user row |
| Wrong code | Safe inline error; bounded attempt increases; no durable applicant |
| Resend then old code | Old code fails; new code remains usable |
| Correct code | One pending row; confirmation email; still no user row |
| Duplicate verified request | Same pending row; no duplicate application |
| Pending applicant tries password/passkey | Methods hidden and backend access denied |
| Manager approves | Audited transition; approval email; signup becomes available |
| Manager rejects | Audited transition; reapply blocked during cooldown |
| Cooldown expires | Fresh verification can reopen the stable row |
| Rate-limit store unavailable | Public cost-bearing send fails according to documented policy |
| Raw API error occurs | HTML page renders a sanitised recovery state, never JSON |
Run concurrency tests for duplicate verification and decision races. Check mobile layout, keyboard navigation, screen-reader labels, OTP paste/autofill, expired-flow recovery, and browser back-button behaviour. Abuse controls that strand legitimate applicants are still product bugs.
The implementation checklist
- Verify email control before persisting the request.
- Keep waitlist entries separate from user accounts.
- Invalidate older codes on resend and consume successful codes once.
- Limit attempts, expiry, IP traffic, and identity traffic independently.
- Add a human challenge before expensive email delivery.
- Keep known and unknown account responses equivalent.
- Hide unrelated sign-in methods from new applicants.
- Sanitize browser errors and log detailed reasons server-side.
- Audit approvals and rejections.
- Allow reapplication only after a defined rejection cooldown.
- Separate transactional waitlist messages from marketing consent.
- Publish retention and deletion behavior.
FAQ
Why verify before adding the email to the waitlist?
Because form submission alone does not prove the address owner requested anything. Verification prevents third parties from creating durable records for victims, improves list quality, and makes the request attributable without creating an account.
Is CAPTCHA enough to stop waitlist spam?
No. CAPTCHA raises automation cost but does not prove email ownership, stop all human abuse, enforce email budgets, prevent duplicate records, or protect against distributed traffic. Use it as one layer in a state machine.
Should an approved applicant become a user immediately?
Usually not. Approval can authorise signup and send an invitation. Create the user when the person completes the account flow, so the user list represents actual accounts and the person's activation remains explicit.
Should waitlist rate-limit errors return HTTP 429?
The API may use 429, but a browser-facing hosted page should render a clear, generic recovery state rather than raw JSON or internal limit detail. Keep known and unknown identity responses structurally equivalent where enumeration is a risk.
What should happen after rejection?
Keep a minimal rejected state for a defined cooldown if needed to prevent immediate loops. After expiry, require a fresh ownership challenge and reopen the stable row with a new event. Do not retain the full rejected application indefinitely without a justified purpose.
A waitlist is part of the authentication perimeter. Build it with the same care as password reset and OTP login, because attackers see all three as ways to make your system send messages and reveal identity state. For the broader endpoint controls, read rate limiting authentication endpoints.