Last March I spent forty minutes tracing one POST request through my own form backend and found three bugs along the way. The request looked simple from the outside. HTML form, action attribute, submit button, email lands. From the inside it was nine separate operations firing in sequence and parallel, with four ways to silently lose the submission and one race condition I had shipped to production without noticing.
This is the walkthrough I wish I had when I started. If you are building anything that takes user input over HTTP (contact forms, signup flows, file uploads, lead capture), most of the work happens in places you cannot see until something breaks. Here is everything that runs between the browser hitting Submit and the form owner getting an email, in execution order, with the code patterns and the failure modes for each step.
TLDR
A modern form backend is not a single endpoint. It is a pipeline of nine stages: rate limit, automation fingerprint, multipart parsing with magic-byte validation, honeypot, optional CAPTCHA, adaptive spam heuristics, database insert, async email notification, and async fanout to webhooks plus chat integrations. Some run sync on the hot path. Most should not. The order matters more than the individual layers.
I built this for FormTo, an open source Formspree alternative. Code on GitHub at github.com/lumizone/formto. Every snippet below is real production code, simplified for clarity.
1. Rate limit check
The first thing a POST handler should do is reject obvious abuse. Cheap to compute, cheap to fail, runs before you touch the body.
The key shape matters more than the limit numbers. Two patterns are tempting and both are wrong on their own:
// wrong: key only on IP
// one shared NAT gateway throttles every legitimate user behind it
const key = `submission:${ip}`
// wrong: key only on endpoint
// one attacker takes the form down for everyone
const key = `submission:${endpoint}`
The correct shape is both axes:
const key = `submission:${ip}:${endpoint}`
const count = await redis.incr(key)
if (count === 1) await redis.expire(key, 60)
if (count > 5) {
return reply.code(429).send({ error: 'Too many requests' })
}
Five requests per IP per endpoint per sixty seconds. Returns 429 if exceeded. Sync, blocks the request. The whole check is one round trip to Redis and adds maybe two milliseconds on the hot path.
2. Automation fingerprint
After rate limit, look at the user agent. Most spam fails here before doing any real work.
const automationSignatures = [
/curl/i, /python-requests/i, /go-http-client/i,
/node-fetch/i, /axios/i, /httpie/i,
/headlesschrome/i, /phantomjs/i, /puppeteer/i,
/scrapy/i, /wget/i
]
if (automationSignatures.some(re => re.test(userAgent))) {
return reply.code(429).send({ error: 'Automation detected' })
}
This is not a strong signal on its own. Determined spammers spoof user agents. The point is it catches the lazy 60% for free before you spend a CPU cycle on parsing or storage.
3. Multipart parsing and file upload
If the form has file inputs, the body is multipart/form-data and you cannot just call JSON.parse. You need a streaming parser.
Three things go wrong here in production.
First, MIME validation. The Content-Type header on each file part is client-controlled. A real bot uploads a PHP shell with Content-Type: image/jpeg. The fix is to validate the file by reading its first eight bytes (magic numbers) and matching against a known list.
const magicBytes = {
jpeg: [0xFF, 0xD8, 0xFF],
png: [0x89, 0x50, 0x4E, 0x47],
pdf: [0x25, 0x50, 0x44, 0x46],
gif: [0x47, 0x49, 0x46, 0x38]
}
function validateMagicBytes(buffer, claimedType) {
const sig = magicBytes[claimedType]
if (!sig) return false
return sig.every((byte, i) => buffer[i] === byte)
}
Second, size limits. The default streaming parsers in Fastify and Multer let everything through unless you configure a cap. Set a per-plan limit (10MB for free users, 25MB for paid, whatever your numbers look like) and reject early. A 50GB upload that bursts memory before you check is your fault, not the attacker's cleverness.
Third, cleanup. The streaming parser writes to a temp file first. Forget the finally block that deletes it and you fill the disk in a week. Ask me how I know.
4. Honeypot check
A honeypot is an invisible form field that humans cannot see (CSS hidden, off-screen positioned, autocomplete tricks). Bots fill in every input they find. If the honeypot has a value, it is a bot.
const honeypotFields = [
'website', '_gotcha', 'honeypot', 'url',
'phone', 'fax', 'company', 'subject',
'address', 'zip', 'country', 'fullname',
'nickname', 'middlename', 'title', 'comment2',
'email_confirm'
]
const isHoneypotTriggered = honeypotFields.some(name => body[name])
Pair it with a timing check. Add a hidden timestamp field on page load and compare against the server time on submit. Anything under three seconds is almost always a bot.
const formAgeMs = Date.now() - parseInt(body._formto_ts, 10)
const isTooFast = formAgeMs < 3000
The clever part is what you return when either trips. Returning 400 tells the bot to mutate inputs and retry. Returning 200 with a fake submission ID convinces the bot the spam landed. It moves on. This single layer catches roughly 80% of the spam I see at zero CPU cost.
5. CAPTCHA verification
Only run this if the form owner enabled it. CAPTCHA costs latency and a provider API call, so I gate it behind a paid plan.
async function verifyTurnstile(token, ip) {
const res = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
secret: process.env.TURNSTILE_SECRET,
response: token,
remoteip: ip
}),
signal: AbortSignal.timeout(1500)
}
)
const json = await res.json()
return json.success === true
}
The trap is that this call sits on the hot path. A Cloudflare or Google API hiccup adds 800 milliseconds to every submission. Set an aggressive timeout (1.5s tops) and decide explicitly what happens if the verification request fails. Fail open and you let bots through. Fail closed and a CAPTCHA outage takes your form down. Pick one. Do not let the network decide for you.
6. Adaptive spam score
What honeypots and CAPTCHA miss, heuristics catch. This is the layer that does the most work without ever showing a challenge to a human.
The score is a stack of signals:
- Temp-email domain (mailinator, 10minutemail, guerrillamail): +3
- Headless browser hints in user agent: +2
- Link density above 30% of message body: +4
- Missing Origin and Referer headers: +1
- Burst: 8 submissions from one client in 300s: blocked
- Burst: 4 submissions from one email in 600s: blocked
- Duplicate fingerprint within 60s: blocked
The fingerprint is a SHA256 of (form ID + client fingerprint + body + file hash). Same fingerprint inside the dedupe window means the user double-clicked or a bot is replaying a single payload.
const fingerprint = crypto
.createHash('sha256')
.update(`${formId}|${clientId}|${JSON.stringify(data)}|${fileHash}`)
.digest('hex')
const acquired = await redis.set(
`dedupe:${fingerprint}`,
'1',
'EX', 60,
'NX'
)
if (acquired === null) {
// duplicate within 60s, silently accept (fake 200, bot moves on)
return reply.code(201).send({ success: true, submissionId: fakeId() })
}
Score above five blocks. Score above three blocks if the request also tripped step 1 or step 2. The goal is to leave humans alone and burn bots.
7. Submission insert
Now you are committed. The request looked legitimate enough to store.
const submission = await db
.from('submissions')
.insert({
form_id: form.id,
form_endpoint: endpoint,
data: cleanedData,
file_urls: uploadedFiles,
metadata: {
ip,
user_agent: userAgent,
referrer,
spam_risk_score: score,
spam_signals: signals
}
})
.select('id')
.single()
The metadata column is the part you will wish you had during incidents. Save more than feels necessary. The spam risk score in particular is gold when a customer complains about missing submissions and you can show them the row was scored, accepted, and emailed.
One race condition burned me here. Forms with a "close after N submissions" feature: two concurrent submitters both check the count, both see it under the limit, both insert. The fix is to enforce the limit inside a Postgres trigger with row-level locking.
CREATE FUNCTION enforce_submission_limit() RETURNS trigger AS $$
DECLARE
current_count int;
max_count int;
BEGIN
SELECT close_after_submissions INTO max_count
FROM forms WHERE id = NEW.form_id FOR UPDATE;
IF max_count IS NULL THEN RETURN NEW; END IF;
SELECT count(*) INTO current_count
FROM submissions WHERE form_id = NEW.form_id;
IF current_count >= max_count THEN
RAISE EXCEPTION 'FORM_SUBMISSION_LIMIT_REACHED';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
SELECT ... FOR UPDATE locks the form row until the transaction commits. Concurrent inserts wait their turn instead of all passing the check.
8. Email notify (fire and forget)
Notifications are the visible product. They are also the thing most likely to fail silently.
The temptation is to await the email send so you can return a clean error to the client. Do not. Network calls to Resend, SendGrid, or Postmark add 200 to 400 milliseconds. Multiply by your traffic and your forms feel slow for no good reason.
// fire after the response is on its way back to the browser
sendNotificationEmail({
to: form.notification_emails,
template: form.email_template_enabled
? form.email_template
: defaultTemplate,
data: cleanedData
}).catch(err => {
logger.error({ submissionId, err }, 'notification email failed')
// alert if failure rate crosses 1% in a 5min window
})
The dangerous part is that fire-and-forget is a lie if you do not track what fired. If you catch the error, log it, and never look at the log, every silent failure looks like a happy path. Add a notification_status field to the submission: pending, sent, failed. Review the failed ones weekly. A proper retry queue is the real answer once you have volume.
9. Fanout (Slack, Discord, Telegram, webhook)
Everything in this step runs after the response is already on its way back to the browser. All async, all fire-and-forget, all with timeouts.
The webhook is the only one with a security model worth showing. The form owner registers a secret. Every webhook delivery includes a timestamp header and an HMAC-SHA256 signature of timestamp.body signed with that secret.
const timestamp = Date.now().toString()
const body = JSON.stringify({
event: 'form.submitted',
form,
submission
})
const signature = crypto
.createHmac('sha256', form.webhook_secret)
.update(`${timestamp}.${body}`)
.digest('hex')
await fetch(form.webhook_url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-formto-timestamp': timestamp,
'x-formto-signature': signature
},
body,
signal: AbortSignal.timeout(10_000)
})
The receiver computes the same signature with the same secret and compares with crypto.timingSafeEqual. If it matches, the request is from you. If it does not, somebody is replaying.
Slack, Discord, and Telegram get block-formatted messages with cruel field limits you only discover in production. Slack caps message blocks at fields[8]. Discord caps embed fields at 15. Telegram messages cap at 4000 characters total. Spammers send forms with 200 fields. Truncate or your formatter throws.
The response
201 Created. JSON body with three keys: success, submissionId, and an optional redirect.
return reply.code(201).send({
success: true,
submissionId: submission.id,
redirect: form.redirect_url || null
})
Validate the redirect URL server-side when the form is configured, never accept it from form data on submit. The javascript:, data:, and unicode-spoofed domains live there.
When you should NOT build this yourself
This is the part most posts skip. After the trace was done and the bugs were fixed, I asked myself if it was worth it. The honest answer for most people is no.
Build your own form backend when:
- You have an unusual storage or compliance requirement (data residency in a specific region, on-prem, custom encryption-at-rest)
- You are already running infrastructure that this slots into (your own Postgres, your own queue, your own observability)
- You need to integrate the submission step into a longer workflow that does not exist in any hosted product
- You have a team of two or more developers who can carry the operational load (paging, monitoring, security patches, customer reports)
Buy a hosted form backend (Formspree, Basin, Web3Forms, FormTo, Netlify Forms) when:
- You are a solo developer or an indie maker shipping a marketing site, a landing page, or a contact form
- The form is not the product, it is a thing the product needs
- You bill yourself for your own time at any rate above zero
I built mine because the form backend IS the product. If I were running a marketing site I would pay nine dollars a month to make this problem go away. The pipeline above is two months of bug fixes and three rewrites in disguise.
Three lessons from rewriting this
The order matters more than the layers. Run cheap checks (rate limit, fingerprint, honeypot) before expensive ones (CAPTCHA, storage, DB). Bots get rejected in five milliseconds, humans never notice the difference.
Fire and forget is a lie. If you do not track what fired and what failed, every silent failure looks like a happy path. Add a status field to every async job. Look at the failures once a week. The customers who quietly leave because notifications stop working will never tell you that is why.
The response shape is part of the API even when the API is public. Bots watch what you return. Returning 200 with a fake ID for honeypot hits is more effective than returning 400, because failure tells them to mutate and retry. Lying is a feature.
Back to the forty minutes
The three bugs I found tracing that one POST request: a missing carriage-return strip in the autoresponder subject (header injection waiting to happen), an await on the email send that pushed every submission to a 600ms response time, and the missing row-level lock on the close-after-N counter. All three were one-line fixes. All three had been in production for weeks.
I kept the trace as a runbook for my future self. The next time something looks slow or something looks lost, I have the map.
If you write form backends by hand, what is the bug you wish you had caught earlier?
Top comments (0)