Email validation at the edge with Cloudflare Workers
· 7 min read · Tutorials
To validate signup emails at the edge, deploy a Cloudflare Worker on the route of your signup endpoint, read the email from the request body, call an email-classification API with a short timeout, and either reject the request with a 422 or forward it to your origin. The Worker below does that in about 100 lines, keeps the API key in a Worker secret, backs off on HTTP 429, and fails open by default so an outage never blocks real signups.
The edge is a good place for this check when your backend is hard to change, or when you have several backends (web, mobile API, a legacy app) that all accept signups and you want one rule in front of all of them.
How the request flows
- A client POSTs to
https://example.com/api/signup. - The Worker, attached to that route, reads the body and extracts
email. - It calls
POST https://pingutil.com/v1/lookupwithskip_enrichment: true, which returns averdictwithout running company enrichment. - If
verdict.is_spamis true, the Worker returns a 422 and the origin never sees the request. - Otherwise it forwards the original request to the origin with an
x-email-screenheader describing what it found.
Per the Cloudflare routes documentation, calling fetch() on the incoming request from a Worker on a route sends a subrequest to your origin as defined by your DNS settings. That's what makes step 5 a one-liner.
Project setup
npm create cloudflare@latest -- signup-guard
cd signup-guard
Choose the Hello World Worker template and TypeScript when prompted. Then edit wrangler.jsonc:
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "signup-guard",
"main": "src/index.ts",
"compatibility_date": "2026-10-01",
"routes": [
{ "pattern": "example.com/api/signup", "zone_name": "example.com" }
],
"vars": {
"FAIL_MODE": "open"
}
}
Store the API key as a secret, not a var. Vars are plain text in your config; secrets are encrypted and hidden after you set them:
npx wrangler secret put PINGUTIL_KEY
For wrangler dev, put the same value in .dev.vars next to the config file and add .dev.vars* to .gitignore, as the Workers secrets docs recommend:
PINGUTIL_KEY=pk_live_...
You can run npx wrangler types to generate an Env type from your config. The code below declares its own small interface so it stands alone.
The Worker
// src/index.ts
interface Env {
PINGUTIL_KEY: string
FAIL_MODE?: 'open' | 'closed'
}
type Verdict = {
is_spam: boolean
low_trust: boolean
reason: string | null
}
type Screen =
| { kind: 'checked'; category: string; verdict: Verdict }
| { kind: 'invalid' }
| { kind: 'unavailable'; why: string }
const LOOKUP_URL = 'https://pingutil.com/v1/lookup'
const TIMEOUT_MS = 1500
// Module scope lives as long as the isolate. Each isolate tracks its own
// backoff, which is enough to stop a burst from hammering a rate-limited key.
let backoffUntil = 0
async function screenEmail(email: string, env: Env): Promise<Screen> {
if (Date.now() < backoffUntil) return { kind: 'unavailable', why: 'rate_limited' }
let res: Response
try {
res = await fetch(LOOKUP_URL, {
method: 'POST',
headers: {
authorization: `Bearer ${env.PINGUTIL_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({ input: email, skip_enrichment: true }),
signal: AbortSignal.timeout(TIMEOUT_MS),
})
} catch (err) {
const why = err instanceof Error && err.name === 'TimeoutError' ? 'timeout' : 'network'
return { kind: 'unavailable', why }
}
if (res.status === 429) {
const body = (await res.json().catch(() => ({}))) as { retry_after_seconds?: number }
backoffUntil = Date.now() + (body.retry_after_seconds ?? 60) * 1000
return { kind: 'unavailable', why: 'rate_limited' }
}
if (res.status === 400) return { kind: 'invalid' }
if (!res.ok) {
if (res.status === 401) console.error('pingutil rejected PINGUTIL_KEY')
return { kind: 'unavailable', why: `http_${res.status}` }
}
const data = (await res.json()) as { category: string; verdict: Verdict }
return { kind: 'checked', category: data.category, verdict: data.verdict }
}
async function readEmail(request: Request): Promise<string | null> {
const type = request.headers.get('content-type') ?? ''
// Read from a clone so the original body can still be forwarded.
const copy = request.clone()
try {
if (type.includes('application/json')) {
const body = (await copy.json()) as { email?: unknown }
return typeof body.email === 'string' ? body.email.trim() : null
}
if (type.includes('form')) {
const form = await copy.formData()
const value = form.get('email')
return typeof value === 'string' ? value.trim() : null
}
} catch {
// Malformed body: let the origin produce its usual error.
}
return null
}
function reject(message: string): Response {
return Response.json({ error: 'email_rejected', message }, { status: 422 })
}
function forward(request: Request, screen: string): Promise<Response> {
const headers = new Headers(request.headers)
// Always overwrite: never trust a value the client sent.
headers.set('x-email-screen', screen)
return fetch(new Request(request, { headers }))
}
export default {
async fetch(request, env): Promise<Response> {
if (request.method !== 'POST') return fetch(request)
const email = await readEmail(request)
if (!email) return forward(request, 'skipped')
const result = await screenEmail(email, env)
switch (result.kind) {
case 'invalid':
return reject('Enter a valid email address.')
case 'checked': {
const { verdict, category } = result
if (verdict.is_spam) {
return reject(
verdict.reason === 'disposable_domain'
? 'Temporary email addresses are not accepted.'
: 'This email address cannot be used to sign up.',
)
}
const tag = verdict.low_trust ? `low_trust:${verdict.reason}` : 'ok'
return forward(request, `${tag};category=${category}`)
}
case 'unavailable':
console.warn('email screen unavailable', result.why)
if (env.FAIL_MODE === 'closed') {
return Response.json(
{ error: 'try_again', message: 'Signups are briefly unavailable. Try again in a minute.' },
{ status: 503, headers: { 'retry-after': '60' } },
)
}
return forward(request, `unchecked:${result.why}`)
}
},
} satisfies ExportedHandler<Env>
Deploy with npx wrangler deploy. Test it with a known throwaway domain:
curl -i -X POST https://example.com/api/signup \
-H "content-type: application/json" \
-d '{"email":"test@mailinator.com","password":"correct horse battery"}'
# HTTP/2 422
# {"error":"email_rejected","message":"Temporary email addresses are not accepted."}
Decisions worth understanding
Read a clone of the body
A request body is a stream and can only be read once. Reading request.clone() leaves the original intact for forward(). Without the clone, the origin receives an empty body and fails in confusing ways.
Short timeout, and nothing else blocks
1.5 seconds is the whole budget. In skip_enrichment mode there is no LLM call, so the API's normal response time sits far below that. If it takes longer, something is wrong and the user shouldn't pay for it.
Honour retry_after_seconds without waiting
pingutil allows 60 requests per minute per key and answers a 429 with { "error": "rate_limited", "retry_after_seconds": 60 }. Sleeping 60 seconds inside a request isn't an option, so the Worker stores backoffUntil and skips the API until it passes. During that window every signup takes the unavailable path. If you regularly see 429s, your signup rate is above what one free key covers, and the API call is in the wrong place for that volume.
Module-level state in a Worker is per isolate, and Cloudflare runs many isolates across locations. Backoff is therefore approximate. For a precise shared counter you'd need a Durable Object, which is more machinery than this problem deserves.
Fail open by default
FAIL_MODE: "open" forwards the request with x-email-screen: unchecked:<why>. Your origin creates the account and can store that header, then re-check those users in a background job. FAIL_MODE: "closed" returns a 503 with Retry-After instead.
Fail open suits most products: a pingutil outage, a network blip or a rate-limit window shouldn't stop real customers from signing up. Fail closed when one fake account directly costs you money, for example signups that receive credits or trigger outbound SMS. Because it's a var, you can flip it with a config change during an abuse wave without touching code.
Don't let clients spoof the header
forward() uses headers.set, which replaces any x-email-screen the client supplied. That only helps if clients can't reach your origin directly. If the origin is publicly reachable on another hostname or IP, an attacker can skip the Worker entirely, so either lock the origin down to Cloudflare traffic or repeat the check in the backend.
What to do with the header at the origin
x-email-screen value |
Suggested handling |
|---|---|
ok;category=company |
Normal account. Good candidate for B2B onboarding. |
ok;category=free |
Normal consumer account. |
low_trust:role_based;... |
Create the account, tag it. Shared inboxes are often legitimate. |
unchecked:timeout and similar |
Create the account, queue a re-check, hold anything that costs you money until it clears. |
skipped |
No email found in the body; your origin's own validation will reject it. |
Extending it
- DNS facts. Add
check_dns: trueto getdomain_signals.can_receive_email. It adds a DNS-over-HTTPS lookup on cache misses, so raise the timeout slightly. Background in DNS-over-HTTPS for domain checks. - Caching. The lookup is keyed on the full address because
verdictdepends on the local part, so repeat hits are rare at signup. If you call the API elsewhere too, see caching domain lookups. - Same logic in the app. If you'd rather keep this in your Next.js code, the server action version uses the identical decision rules.
If you haven't called the API before, run through the quickstart first, and keep the API reference open for field names. For why a blocklist alone isn't enough here, see how to detect disposable email addresses.
Questions people ask
- Can a Cloudflare Worker validate emails without changing my backend?
- Yes. Put the Worker on a route in front of your signup endpoint. It reads the email from the request, rejects clearly bad addresses itself, and forwards everything else to your origin unchanged apart from one added header.
- Does waiting on an external API count against a Worker's CPU time limit?
- Cloudflare measures CPU time separately from wall-clock time, and time spent waiting on a fetch subrequest is not CPU time. You still want a short timeout so users aren't left waiting.
- Where should I store the API key in a Worker?
- As a secret, set with npx wrangler secret put PINGUTIL_KEY, and read it from env.PINGUTIL_KEY in the fetch handler. For local development put it in a .dev.vars file that is not committed.