Half your waitlist is bots? Three server-side checks in Node
Normalise emails before a unique index, drop domains that can't take mail, and only count people who click the confirmation link, all on the server where scripts can't skip them.
You open your waitlist table and something is off. Rows arrive in bursts, half of them are the same Gmail address with different dots and plus tags, and a fair number sit on domains that don't exist. If you're about to quote that number to anyone, it's worth cleaning up first.
This post covers three checks in plain Node and Postgres, plus a rate limit. Every snippet below runs on Node 20 with ES modules ("type": "module" in package.json).
Why the checks have to live on the server
Your form is not the gate. A signup form is HTML and JavaScript that ends in a request to an endpoint, and anyone can send that request without the form. Bots usually do exactly that: they read the network tab once, then post straight to /api/waitlist in a loop. Client-side validation, hidden honeypot fields and disabled buttons never run.
So whatever decides "this counts" has to run in the handler that receives the request. Everything below does.
1. Normalise the email before the unique check
A unique constraint on the raw email doesn't stop much. J.Doe+7@GMAIL.com, jdoe@gmail.com and j.doe@googlemail.com are three different strings and, for Gmail, one inbox. So store a second column, a matching key, and put the unique index on that.
The rules:
- trim whitespace and lowercase the whole address
- drop everything from the first
+in the part before the@ - remove dots only for
gmail.comandgooglemail.com(other providers treat dots as significant, soa.b@outlook.comandab@outlook.comstay different) - map
googlemail.comtogmail.com
// email-key.js
export function emailKey(raw) {
const email = String(raw).trim().toLowerCase()
const at = email.lastIndexOf('@')
if (at < 1 || at === email.length - 1) return null
let local = email.slice(0, at).split('+')[0]
let domain = email.slice(at + 1)
if (domain === 'googlemail.com') domain = 'gmail.com'
if (domain === 'gmail.com') local = local.replace(/\./g, '')
return local ? `${local}@${domain}` : null
}
Test it with the built-in runner (node --test):
// email-key.test.js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { emailKey } from './email-key.js'
test('emailKey', () => {
assert.equal(emailKey(' J.Doe+7@GMAIL.com '), 'jdoe@gmail.com')
assert.equal(emailKey('j.doe@googlemail.com'), 'jdoe@gmail.com')
assert.equal(emailKey('a.b+x@outlook.com'), 'a.b@outlook.com')
assert.equal(emailKey('+x@gmail.com'), null)
assert.equal(emailKey('no-at-sign'), null)
})
Use the key only for matching. Keep the address as typed for sending, because that's what the person gave you.
-- schema.sql
create table waitlist (
id bigint generated always as identity primary key,
email text not null, -- as typed, used for sending
email_key text not null, -- from emailKey(), used for matching
status text not null default 'pending'
check (status in ('pending', 'confirmed')),
token_hash text,
created_at timestamptz not null default now(),
confirmed_at timestamptz
);
create unique index waitlist_email_key_idx on waitlist (email_key);
Now j.doe+7 stops counting twice, and the database enforces it even if two requests race.
2. Drop domains that can't take mail
If the domain has no mail setup, a confirmation email has nowhere to go, so there's no point storing the row. node:dns/promises gives you everything you need, but there are three cases to get right:
- ENOTFOUND: the domain doesn't exist (NXDOMAIN). Reject.
- ENODATA: the domain exists but has no MX records. This is not an instant reject. RFC 5321 says a sender falls back to the domain's A or AAAA record in that case, so check those before deciding.
- Null MX (RFC 7505): a single MX record with preference 0 and exchange
.is the domain owner saying "this domain accepts no mail". Node may give you the root exchange as an empty string rather than., so check for both.
Anything else (timeouts, SERVFAIL, refused connections) means DNS is having a bad moment, not that the address is bad. Fail open: let the signup through and let the confirmation step deal with it. A DNS outage shouldn't lock out genuine people.
// mail-domain.js
import { Resolver } from 'node:dns/promises'
const defaultResolver = new Resolver({ timeout: 1500, tries: 2 })
const settle = (p) => p.then((records) => ({ records }), (err) => ({ code: err.code }))
function withTimeout(promise, ms) {
let timer
const timeout = new Promise((resolve) => {
timer = setTimeout(() => resolve('unknown'), ms)
})
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
}
// Returns 'ok', 'no-domain', 'no-mail' or 'unknown' (DNS trouble: let it through)
export function mailDomain(domain, dns = defaultResolver, ms = 3000) {
return withTimeout(check(domain, dns), ms)
}
async function check(domain, dns) {
const mx = await settle(dns.resolveMx(domain))
if (mx.records?.length) {
const [first] = mx.records
const nullMx = mx.records.length === 1 && first.priority === 0 &&
(first.exchange === '' || first.exchange === '.')
return nullMx ? 'no-mail' : 'ok'
}
if (mx.code === 'ENOTFOUND') return 'no-domain'
if (mx.code && mx.code !== 'ENODATA') return 'unknown'
// No MX records: RFC 5321 falls back to the address records
const [a, aaaa] = await Promise.all([settle(dns.resolve4(domain)), settle(dns.resolve6(domain))])
if (a.records?.length || aaaa.records?.length) return 'ok'
const gone = (r) => r.records || ['ENODATA', 'ENOTFOUND'].includes(r.code)
return gone(a) && gone(aaaa) ? 'no-mail' : 'unknown'
}
The Resolver options cap each query, and withTimeout caps the whole check, so a slow name server costs you at most three seconds before the signup goes through.
You don't need a live domain to test each branch. Pass a fake resolver:
// mail-domain.test.js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mailDomain } from './mail-domain.js'
const fail = (code) => () => Promise.reject(Object.assign(new Error(code), { code }))
const ok = (records) => () => Promise.resolve(records)
const fake = (mx, a = fail('ENODATA'), aaaa = fail('ENODATA')) =>
({ resolveMx: mx, resolve4: a, resolve6: aaaa })
test('mailDomain', async () => {
assert.equal(await mailDomain('x', fake(ok([{ exchange: 'mx.x', priority: 10 }]))), 'ok')
assert.equal(await mailDomain('x', fake(ok([{ exchange: '', priority: 0 }]))), 'no-mail')
assert.equal(await mailDomain('x', fake(ok([{ exchange: '.', priority: 0 }]))), 'no-mail')
assert.equal(await mailDomain('x', fake(fail('ENOTFOUND'))), 'no-domain')
assert.equal(await mailDomain('x', fake(fail('ENODATA'), ok(['192.0.2.1']))), 'ok')
assert.equal(await mailDomain('x', fake(fail('ENODATA'))), 'no-mail')
assert.equal(await mailDomain('x', fake(fail('ESERVFAIL'))), 'unknown')
const hang = () => new Promise(() => {})
assert.equal(await mailDomain('x', fake(hang), 50), 'unknown')
})
This only tells you about the domain. It says nothing about the part before the @, which is what step 3 is for.
3. Only count confirmed entries
Bots rarely open an inbox. So every new row starts as pending, you email a one-time link, and the row only flips to confirmed when someone clicks it. Your waitlist number is the confirmed count, nothing else.
Here is the whole handler, with the rate limit from the next section first in line. It takes any client with a query(sql, params) method that returns { rows }, such as a pg Pool.
// waitlist.js
import { randomBytes, createHash } from 'node:crypto'
import { emailKey } from './email-key.js'
import { mailDomain } from './mail-domain.js'
import { allow } from './rate-limit.js'
const sha256 = (s) => createHash('sha256').update(String(s)).digest('hex')
export async function joinWaitlist({ email, ip }, db, sendLink) {
if (!allow(ip)) return { status: 429 }
const key = emailKey(email)
if (!key) return { status: 400 }
const domainStatus = await mailDomain(key.split('@')[1])
if (domainStatus === 'no-domain' || domainStatus === 'no-mail') return { status: 400 }
const token = randomBytes(32).toString('base64url')
const { rows } = await db.query(
`insert into waitlist (email, email_key, token_hash) values ($1, $2, $3)
on conflict (email_key) do update
set email = excluded.email, token_hash = excluded.token_hash, created_at = now()
where waitlist.status = 'pending'
returning id`,
[email.trim(), key, sha256(token)])
if (rows.length) await sendLink(email.trim(), token)
return { status: 202 } // same answer for new, resent and already confirmed
}
export async function confirmEntry(token, db) {
const { rows } = await db.query(
`update waitlist set status = 'confirmed', confirmed_at = now(), token_hash = null
where token_hash = $1 and status = 'pending'
and created_at > now() - interval '7 days'
returning id`,
[sha256(token)])
return rows.length === 1
}
A few details worth keeping:
- Only a hash of the token is stored, so a leaked table can't be used to confirm entries.
- Signing up again while pending stores the new spelling, sends a fresh link to it and resets the clock. Signing up again once confirmed changes nothing.
- The response is the same either way, so the endpoint doesn't tell anyone which emails are already on the list.
Then expire the leftovers on a schedule, and count only what's confirmed:
-- maintenance.sql
delete from waitlist
where status = 'pending' and created_at < now() - interval '7 days';
select count(*) as waitlist_size from waitlist where status = 'confirmed';
Pick the number of days that suits you. Seven is a reasonable start.
4. A per-IP rate limit
A script that can't fake confirmations can still fill your pending table and your email sending quota. A simple per-IP cap in front of everything helps:
// rate-limit.js
const hits = new Map()
export function allow(ip, limit = 5, windowMs = 60 * 60 * 1000, now = Date.now()) {
const entry = hits.get(ip)
if (!entry || now - entry.start >= windowMs) {
hits.set(ip, { start: now, count: 1 })
return true
}
entry.count += 1
return entry.count <= limit
}
Three notes. This Map lives in one process, so on several instances or serverless functions, keep the counter in Redis or a Postgres table instead. Read the client IP from your proxy's trusted header, not whatever the request claims. And for IPv6, count per /64 rather than per address, since one connection usually gets a whole range.
Putting it together
On the server, in this order: rate limit, normalise, check the domain, store as pending, count only confirmed. None of it needs a third-party service, and all of it runs where a script posting straight to your endpoint can't skip it.
I built Orisift
I built Orisift because I kept writing these checks for every project. It runs the domain checks (mail setup and a throwaway domain list) plus phone and IP screening in one call, through POST /v1/lookup or POST /v1/signup, and @orisift/sdk includes a Supabase Auth helper. The homepage has a free checker, three previews a day with no account: https://orisift.com/x?m=post&c=hashnode-waitlist-bots&to=%2F
Signing up gives 500 free credits once, and there's no card to enter.
