Skip to main content
DEPLOY1 Blog
Agency site
← All posts

Server-side lead intake without a form framework

How we replaced a direct-to-provider form POST with a first-party endpoint that validates every field server-side, honeypots bots and rate-limits abuse — in a couple of hundred lines.

Every marketing site needs a contact form, and there are a thousand ways to build one. Most of them share one flaw: the browser talks directly to a third-party provider, and the provider is asked to trust whatever the browser sends.

Our consultation form used to work that way. This post is about the replacement: a first-party intake endpoint that treats incoming submissions as untrusted input, the same way we'd treat any other API traffic.

The problem with the naive approach

The classic implementation is a form whose action points at a provider's URL with a few hidden fields:

<form action="https://provider.example/submit" method="POST">
  <input type="hidden" name="_captcha" value="false" />
  <input type="text" name="name" required />
  ...
</form>

Problems with this, in order of severity:

  • No server-side validation. required is a browser hint. Anyone can send whatever they want to the provider with a curl one-liner — including spam, HTML and malicious URLs.
  • The provider address is public. The destination email lives in the page source, which means it's in spam databases almost immediately.
  • No rate limiting. The endpoint is open to the internet by design. That's a spam pump waiting to happen.

The architecture: static page, dynamic endpoint

A static site can't render server-side logic, but it doesn't have to. It only needs to call it. Our endpoint lives in the same Cloudflare Worker that serves the site:

  • The browser POSTs JSON to /api/lead — same origin, no CORS.
  • The worker validates every field, applies a honeypot check and rate limiting.
  • If valid, the worker relays the submission to the email provider server-side, where the destination address is never exposed to the client.
async function handleIntake(request, env) {
  if (request.method !== "POST") return json(405, { error: "Method not allowed." });

  const body = await request.json(); // rejects non-JSON
  const parsed = parseIntakePayload(body); // full validation
  if (!parsed.value) return json(400, { error: parsed.error });

  await relayToEmail(env, parsed.value); // server-side delivery
  return json(200, { ok: true });
}

Validation: treat it like an API input

The browser's built-in validation is a convenience for humans, not a security boundary. So the worker re-validates everything with explicit length and shape limits:

function parseIntakePayload(body) {
  const name = trimTo(body.name, 120);
  const email = trimTo(body.email, 254);
  if (!isPlausibleEmail(email)) return { error: "Email address is not valid." };
  if (body.website && !isSafeUrl(body.website)) return { error: "Website URL is not valid." };
  // ...
}

Three details that matter:

  • Length limits everywhere. Don't just validate the email — bound every string. A 2 MB "company name" is a disk and logging concern.
  • Normalize, then validate. Trimming before checking means " a@b.com " passes on purpose, and " "<script>…" fails on the email check rather than on a stack of trimming edge cases.
  • Never reflect user input verbatim. Error messages describe what was wrong; they don't echo the offending value back, which prevents both stored reflection and noisy inputs.

The honeypot and the rate limit

Spam is a numbers game, so we play the numbers:

  1. Honeypot. A field that's invisible to humans (aria-hidden, positioned off-screen, tabindex="-1"). Real users never fill it in; bots that naively fill every field do. If it's non-empty, we reject silently — a real user never sees the error.

  2. Rate limiting. Keyed on cf-connecting-ip plus the current time window:

const key = `intake:${ip}:${Math.floor(Date.now() / WINDOW_MS)}`;
const current = Number((await kv.get(key)) ?? "0");
if (current >= MAX) return json(429, { error: "Too many submissions." });
await kv.put(key, String(current + 1), { expirationTtl: WINDOW_SECONDS + 30 });

We keep an in-memory fallback so the endpoint still rate-limits even if KV is unavailable — and the IP is never stored beyond the expiry of the rate-limit counter, so the limiter retains no personal data.

What the visitor actually experiences

On the page, the form submits with fetch and JSON, and the UI responds to the three states independently: success, a recoverable validation error, or the rate-limit message. The endpoint returns structured JSON so the client never has to sentence-parse an HTML page to know whether the send worked.

The whole thing — validation, honeypot, rate limiting, relay — comes to a couple of hundred lines, and it's covered by unit tests that stub the relay and assert each failure mode returns the right status code.

The transferable lesson

The principle here generalizes beyond forms: any browser-shipped "validation" is presentation, and any third-party endpoint you trust by default is a risk. Move the boundary where the security actually lives — the server — and treat every submission like the potentially hostile input it is.

Your contact form is your first customer-facing system. It deserves the same care as your last one.