Form submission security: what actually protects a public endpoint
Short answer: Assume anyone can POST to the endpoint. Then make the endpoint safe for anonymous writes: strict size and shape limits, shared rate limits, quota enforcement, no read access from the public path, and storage scoped by ownership rather than by request parameters.
A contact form endpoint is reachable by the entire internet, including curl. Any security model that depends on the browser behaving is not a security model.
The threat model, briefly
- Flooding: filling your database or your quota.
- Injection: values that break a downstream consumer, spreadsheet or email template.
- Enumeration: discovering which form ids exist and are active.
- Data exposure: reading submissions through the write path.
- Privilege confusion: a viewer of one form reaching another's data.
What works
- 1Body caps enforced while reading, not only from Content-Length.
- 2Field count, name length, value length and nesting depth limits.
- 3Rate limits stored in a shared database, keyed on a trustworthy IP.
- 4Atomic quota checks in the same transaction as the insert.
- 5Write-only public path: no GET, no listing, no ids leaked beyond the caller's own submission.
- 6Authorization on every read derived from the session and the ownership chain, never from a query parameter.
- 7Output encoding everywhere the data is rendered, including CSV exports.
HTTP/1.1 409 Conflict
Content-Type: application/json
Cache-Control: no-store
{ "success": false, "error": { "code": "QUOTA_EXCEEDED", "message": "This form has reached its monthly submission limit." } }Note what is absent: no table names, no SQL, no stack trace, no indication of which validation happened first. Errors are for the integrator, not the attacker.
What is theatre
| Measure | Why it does not protect you |
|---|---|
| A secret in the HTML | It is in the page source; it is a form id with extra steps |
| Hiding the field names | Bots read the form markup they are about to submit |
| Client-side validation | Bypassed by one curl command |
| `X-Frame-Options` on the endpoint | Framing is a page concern, not an API one |
| Obscure endpoints | Security by URL guessing; logs find them |
Authorization is a separate system
Spam protection keeps garbage out; authorization keeps data in. They are enforced in different places. In Formivo, the database has row-level security policies so a workspace member can only select submissions for forms their workspace owns, and a client viewer only for forms they hold an active permission on. The server checks the same rules before querying, because a service-role connection bypasses RLS by design.
Operational hygiene
- Log operational signals (rate limit hit counts), not payloads.
- Never log raw tokens, emails or message bodies.
- Keep audit records for access grants and revocations.
- Set retention deliberately; deleted data cannot leak.
None of this makes an endpoint unhackable. It makes anonymous writes bounded, reads authenticated and cross-tenant access a server-side impossibility rather than a frontend convention.