· 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-duplicate and 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-store and 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-inline is 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?