# Harden a public form endpoint

> Harden a public form API with body-size limits, exact input shapes, explicit consent, origin allowlists, neutral responses, and two rate limits.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-08-02 | Updated: 2026-08-03 | Topics: [Cloudflare](https://flaviocopes.com/tags/cloudflare/) | Canonical: https://flaviocopes.com/harden-public-form-endpoint/

A public signup endpoint accepts input from strangers.

Treat every byte as untrusted.

For a waiting list, the valid request is tiny: one email address and one consent value. We can reject everything else before it reaches the database or email provider.

## Limit the body while reading it

Checking `Content-Length` is useful, but it is not enough. A client can omit it or lie.

Enforce the limit while reading the stream:

~~~js
async function readSmallBody(request, maximum = 2048) {
  const reader = request.body?.getReader()
  if (!reader) return null

  const chunks = []
  let received = 0

  while (true) {
    const { done, value } = await reader.read()
    if (done) break

    received += value.byteLength
    if (received > maximum) {
      await reader.cancel()
      return null
    }

    chunks.push(value)
  }

  const body = new Uint8Array(received)
  let offset = 0

  for (const chunk of chunks) {
    body.set(chunk, offset)
    offset += chunk.byteLength
  }

  return new TextDecoder().decode(body)
}
~~~

This stops an oversized request instead of buffering the whole thing first.

Two kilobytes is generous for an email address and a checkbox.

## Accept an exact input shape

Do not silently accept extra fields.

For JSON, require exactly `email` and `consent`:

~~~js
function parseSignup(body) {
  if (!body || typeof body !== 'object') return null
  if (Array.isArray(body)) return null

  const keys = Object.keys(body)
  if (keys.length !== 2) return null
  if (!keys.includes('email')) return null
  if (!keys.includes('consent')) return null

  if (body.consent !== true) return null

  return parseEmail(body.email)
}
~~~

The same rule should apply to URL-encoded forms.

Exact shapes make the endpoint easier to reason about. They also stop forgotten or attacker-supplied fields from becoming behavior later.

## Require explicit consent

The presence of a `consent` key is not consent.

Accept a small set of values your clients intentionally send:

~~~js
function consentAccepted(value) {
  return value === true ||
    value === 1 ||
    value === '1' ||
    value === 'yes' ||
    value === 'on'
}
~~~

Store when consent happened and which wording version the user saw.

That turns consent from a checkbox in the interface into a fact in the data model.

## Restrict browser origins

If other sites embed your form endpoint, give each waiting list an origin allowlist:

~~~js
function originAllowed(allowedOrigins, origin) {
  if (!origin) return true
  if (allowedOrigins === '*') return true

  return allowedOrigins
    .split(',')
    .map(value => value.trim())
    .includes(origin)
}
~~~

Return `Vary: Origin` when the response depends on the origin.

Remember what CORS does. It controls browser access to responses. It does not stop a script, bot, or server from sending requests.

We still need rate limiting.

## Rate-limit the client and the email

An IP limit stops one client from sending a burst:

~~~js
const ip = request.headers.get('cf-connecting-ip') ?? 'local'
const { success: ipAllowed } = await env.RATE_LIMIT.limit({
  key: `${list.id}:ip:${ip}`
})

if (!ipAllowed) {
  return Response.json(
    { ok: false, error: 'Too many requests' },
    { status: 429 }
  )
}
~~~

But an attacker can distribute requests across many IP addresses and target one email.

Add a second limit keyed by the normalized email:

~~~js
const emailKey = await hashRateLimitKey(
  secret,
  `email-rate:${email}`
)
const { success: emailAllowed } = await env.RATE_LIMIT.limit({
  key: `${list.id}:email:${emailKey}`
})

if (!emailAllowed) {
  return acceptedResponse()
}
~~~

Hash the email before using it as infrastructure metadata.

Cloudflare's Rate Limiting binding is intentionally permissive and local to a Cloudflare location. The email-key limit above is a fast first layer, not a global recipient guarantee.

Use D1 or a Durable Object for a global cooldown. This D1 statement atomically claims one email hash for ten minutes:

~~~sql
CREATE TABLE signup_email_cooldowns (
  email_hash TEXT PRIMARY KEY,
  next_allowed_at INTEGER NOT NULL
);
~~~

~~~sql
INSERT INTO signup_email_cooldowns (
  email_hash,
  next_allowed_at
)
VALUES (?, ?)
ON CONFLICT (email_hash) DO UPDATE SET
  next_allowed_at = excluded.next_allowed_at
WHERE signup_email_cooldowns.next_allowed_at <= ?
RETURNING email_hash;
~~~

Bind the email hash, `now + 600`, and `now`, using Unix seconds. If the statement returns no row, return the same neutral `202` response used for a duplicate signup.

The two limits protect different resources:

- the IP limit protects the endpoint
- the email limit protects the recipient

## Return a neutral success message

Do not reveal whether the address already exists:

~~~js
function acceptedResponse() {
  return Response.json({
    ok: true,
    message: 'Request received. Check your inbox if confirmation is needed.'
  }, { status: 202 })
}

return acceptedResponse()
~~~

This makes account enumeration harder.

It also gives duplicate submissions the same user experience as new ones.

## Put the checks in order

My preferred order is:

1. find the requested list
2. validate the origin
3. rate-limit the client
4. read the bounded body
5. validate the exact input
6. rate-limit the normalized email
7. write to the database
8. send the confirmation

Reject cheap failures early.

The database and email provider should only see requests that passed every boundary before them.
