Harden a public form endpoint
By Flavio Copes
Harden a public form API with body-size limits, exact input shapes, explicit consent, origin allowlists, neutral responses, and two rate limits.
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:
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:
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:
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:
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:
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:
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:
CREATE TABLE signup_email_cooldowns (
email_hash TEXT PRIMARY KEY,
next_allowed_at INTEGER NOT NULL
);
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:
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:
- find the requested list
- validate the origin
- rate-limit the client
- read the bounded body
- validate the exact input
- rate-limit the normalized email
- write to the database
- send the confirmation
Reject cheap failures early.
The database and email provider should only see requests that passed every boundary before them.
Related posts about cloudflare: