Cybersecurity · Threat modelling · Backend · Cloudflare Workers
Threat-modelling a contact form: defence in depth on a very small API
A contact form looks like the most boring feature on a website. It is also a public endpoint that accepts text from strangers and sends it into a database and an inbox. Here is how I threat-modelled the one on this site, layer by layer, and where it is still weak.
The contact form on this website is about forty lines of form and one API route. It is easy to treat it as a detail. But look at it from the other side: it is a public endpoint, open to the whole internet, that takes text from strangers and writes it into a database and an email inbox. That is exactly the kind of feature attackers look for, because nobody watches it.
So before I shipped it, I sat down and threat-modelled it. This post walks through that process: what could go wrong, what I built against each threat, and, just as important, where the design is still weak.

Start with the data flow
Threat modelling starts with drawing how data moves. Mine is short:
- A visitor fills in the form in their browser.
- The browser sends JSON to
POST /api/contact, a small Hono app running on Cloudflare Workers. - The Worker validates the input, checks for abuse, and stores the message in a D1 (SQLite) database.
- The Worker emails me a notification, with the visitor’s address as
Reply-To.
Every arrow in that list is a trust boundary: browser to API, API to database, API to email. Each one is a place where untrusted input crosses into something I care about. For each boundary I asked the questions behind the STRIDE model: could someone spoof something, tamper with data, deny what they did, disclose information, deny service, or elevate their privileges?
For a contact form, three threats rise to the top: abuse (spam and flooding), injection (into the database and into email), and privacy (I am now holding other people’s names, emails and words).
Layer 1: assume the browser is lying
The first rule is that the form in the browser is a suggestion, not a control. Anyone can skip it and send requests with curl. So every check happens again on the server:
- The body must be valid JSON, or the request is rejected with a
400. namemust be present and at most 100 characters.emailmust match a basic pattern and be at most 200 characters.intentmust be one of a fixed list (project,design,teaching,speaking,other). This is an allow-list: I don’t try to guess what bad input looks like, I only accept what good input looks like.messagemust be between 10 and 5,000 characters.
Length limits are not just tidiness. They put a ceiling on how much storage and email one request can consume, which matters later when we talk about flooding.
The same idea applies to CORS. The API only returns CORS headers to origins on an allow-list (my domain and its preview deployments). But I don’t count CORS as a security control here. It stops other websites from using a visitor’s browser to call my API; it does nothing against a script talking to the API directly. Knowing which control protects against which attacker is half of the work.
Layer 2: make spam expensive
Spam is the most likely attack on any contact form, so it gets three separate layers. None of them is enough alone; together they raise the cost.
A honeypot. The form has a hidden company field that people never see and never fill in. Simple bots fill in every field. If company has a value, the API returns { ok: true } and quietly throws the message away. Returning success is deliberate: an error would teach the bot’s author that they were caught.
A rate limit. Each IP address gets five submissions per hour, counted in Cloudflare KV with a one-hour expiry. Past that, the API returns 429 Too Many Requests. The count is taken before the bot check, so a script that keeps failing still burns through its own quota.
A challenge. The form uses Cloudflare Turnstile, and the server verifies the token with Cloudflare before storing anything. The secret key lives in a Worker secret, never in the repository or the page.
This is defence in depth at small scale: the honeypot catches lazy bots for free, Turnstile catches most of the rest, and the rate limit caps the damage from anything that gets through both.
Layer 3: injection, in two places people forget
Most developers know about SQL injection. The insert into D1 uses a parameterised query:
await c.env.DB.prepare('INSERT INTO messages (name, email, intent, body) VALUES (?, ?, ?, ?)')
.bind(name, email, intent, message)
.run();
The visitor’s text is passed as data, never glued into the SQL string, so a message containing '); DROP TABLE messages; -- is stored as exactly that: a strange message.
The second injection point is less famous: email headers. The notification email is built by hand as a MIME message, and the visitor’s name and email end up in the Subject and Reply-To headers. Email headers are separated by line breaks. If a visitor’s “name” contained a line break followed by Bcc: [email protected], a careless implementation would add a new header and turn my server into a spam relay. So every value that goes into a header passes through a function that replaces line breaks with spaces first, and non-ASCII names (like Kɔfi or 李明) are encoded properly rather than passed raw.
The email body is plain text, not HTML, so a message full of <script> tags is just text when I read it.
Layer 4: the boring headers that do a lot
The website itself is served with strict security headers: a Content Security Policy that only allows scripts from my own domain and the two Cloudflare services I use, frame-ancestors 'none' and X-Frame-Options: DENY against clickjacking, HSTS so browsers always use HTTPS, and a Permissions-Policy that switches off the camera, microphone and location. None of this is exciting, and that is the point. Good security is mostly boring decisions made consistently.
Where it is still weak
A threat model that finds no weaknesses is not a threat model. These are the ones I know about:
- IP-based rate limits punish shared networks. Many mobile networks put large numbers of users behind a small pool of shared IP addresses (carrier-grade NAT). On a network like that, one abusive user could use up the hourly limit for innocent people who happen to share their address. For a personal site the trade-off is acceptable, but it is a real fairness problem for services built for African mobile users, and it is one I want to think about more.
- The rate limit is approximate. KV is eventually consistent, and reading then writing the counter is not atomic, so a fast burst of parallel requests can slip slightly past the limit. That is fine for damping spam; it would not be fine for, say, limiting login attempts, where I would use a strongly consistent store.
'unsafe-inline'is still in the script policy. The static site uses a few inline scripts, so the CSP allows them. This weakens the CSP’s protection against cross-site scripting. The proper fix is to move to hashes or nonces for each inline script.- Messages are kept indefinitely, in plain text. Right now there is no retention policy. Under Ghana’s Data Protection Act, 2012 (Act 843), and good practice everywhere, I should only keep personal data as long as I need it. A scheduled job that deletes messages after a fixed period is the obvious next step.
I’ve ranked these four and written up how I plan to fix each one in a follow-up post.
What I took from it
Two things stayed with me.
First, security starts with the question “who is on the other side of this endpoint?”, not with a list of tools. Turnstile, KV and parameterised queries are only useful because the threat model said where to put them.
Second, the small, unglamorous features are where the risk hides. Nobody pen-tests the contact form. It is precisely the systems that seem too small to matter, a school’s results portal, a church’s donation page, a small NGO’s sign-up form, that hold real people’s data with the least protection. Those are the systems I care most about making safer, and they are where I want my research to go.
If you spot something I have missed, I would genuinely like to know: get in touch. Yes, through the form.
