· 5 min read
A contact form endpoint that assumes hostile input
Replacing a browser-side email service with a Cloudflare Worker endpoint. Every request goes through size limits, strict validation, a honeypot, rate limiting and server-side Turnstile before a single email is sent.
- Cloudflare Workers
- API Security
- Turnstile
- TypeScript
Why not send from the browser
The first version of this site sent email straight from the browser through a third-party service. It works, but the browser holds everything an attacker needs: the service identifiers, the template and the freedom to call it as often as they like. Spam protection is whatever the service provides, and the recipient is one edit away from being somebody else.
The rewrite moves sending to a POST /api/contact endpoint on the same Cloudflare Worker that
serves the static site. The browser sends four fields and a Turnstile token; everything else is
decided on the server.
The pipeline
The checks run from the cheapest to the most expensive, and nothing is sent unless every one of them passes:
| Step | Rejected with |
|---|---|
Method is POST |
405 |
Content-Type is JSON |
415 |
| Body at most 16 KB, by header and by actual size | 413 |
| Body is valid JSON | 400 |
| Fields valid: name 2–100, email up to 254, message 10–5000 characters, token present | 422 with per-field messages |
| Hidden honeypot field empty | fake 200, nothing sent |
| Per-IP rate limit: 5 requests per minute | 429 with Retry-After |
| Turnstile verified server-side | 403, or 503 if verification is unreachable |
| Email accepted by the provider | 502 |
Validation also rejects control characters and line breaks in fields that end up in email
headers, so a name such as Ada\r\nBcc: someone@example.com never reaches the mail layer.
The honeypot answers with a plausible success on purpose: a bot that fills every field learns nothing from the response.
The rate limiter uses the Workers Rate Limiting binding, keyed on the client IP. If the binding itself fails, the endpoint fails open and relies on Turnstile, which still runs on every request. Blocking every visitor because a counter is unavailable would turn a protection into an outage.
Turnstile, verified on the server
The widget in the page only produces a token. The decision happens in the Worker, which sends the token to Siteverify with the secret key, the visitor’s IP and an idempotency key, with an 8-second timeout. A successful answer is not enough on its own:
- Hostname. The token must have been issued on one of the hostnames allowed for that environment. A token solved on another site is rejected.
- Action. The widget sets the action
contact, and the Worker requires it, so a token produced for another form on the same domain does not work here. - Single use. A replayed or expired token comes back as
timeout-or-duplicateand gets its own message, so a visitor who waited too long knows to solve the challenge again.
One detail cost some time: Turnstile’s test keys answer with the hostname example.com and no
action at all. In local development only, the Worker tolerates a missing action, while a wrong one
is still rejected. Production and staging always require it.
Fixed recipients, escaped content
The recipient and the sender address come only from the Worker’s configuration. The request body
has no field that can change them, and extra fields such as to or from are ignored. The
visitor’s address is used only as Reply-To, after validation.
Before building the message, every value that goes into a header is stripped of line breaks and control characters, and every value that goes into the HTML body is escaped. The provider’s API takes structured JSON rather than raw headers, but the sanitising does not depend on that.
Talking to the email provider
Sending goes through the provider’s HTTP API with a 5-second timeout per attempt. Network errors
and 5xx answers get exactly one retry, with the same idempotency key, so a request that reached
the provider before timing out is never delivered twice. 4xx answers, such as an invalid key or
an unverified domain, are not retried.
The client sees only a generic code and message. The Worker log records the provider’s error name and HTTP status, never the API key, the provider’s free text or the visitor’s data. The Illegal invocation writeup shows why the real error must still be logged somewhere.
What the browser sees
- JSON responses with
Cache-Control: no-storeand no stack traces. - No CORS headers at all: the endpoint is same-origin, and other origins cannot call it from a browser.
- A Content Security Policy that allows scripts only from the site itself and from Turnstile. The
build contains no inline scripts, so
unsafe-inlineis never needed for scripts. - The Turnstile script is loaded only when the form comes close to the viewport or gets focus.
Staging that cannot impersonate production
Staging is a separate Worker on its own subdomain, with its own secrets and its own rate-limit
namespace. Its list of allowed hostnames contains only the staging host, and production’s contains
only the production hosts. A token solved on staging is therefore rejected by production and the
other way round, and both directions are covered by tests. Every staging response also carries
X-Robots-Tag: noindex, so the copy never competes with the real site in search results.
Tests
The Worker has a Vitest suite that covers each step of the pipeline: limits, validation, honeypot, rate limiting, every Turnstile outcome, the email client’s retries and errors, and that neither the secret nor the API key ever appears in a response. CI runs it on every push, together with a Wrangler dry run for both environments.
Unit tests have limits, though: they run in Node, not in the Workers runtime. The bug in the Illegal invocation writeup passed all of them.
# Questions, corrections or a similar problem to solve?