I Built a Form That Lives on Other People's Websites. Here's How I Kept It From Being Abused.
/ For developers / 13 min read
Five security gates from my embeddable lead capture widget, from rate limits to honeypots, plus a checklist to protect your own website forms.

Imagine placing a small box on the counter of a shop you don't own. Anyone who walks in can drop a note into it, and every note goes straight into your own warehouse. You don't control the shop. You don't know who walks in. Some visitors are genuine customers. Some are pranksters. Some are robots that can drop ten thousand notes a minute.
That is exactly what an embeddable widget is. Think of the contact forms, newsletter sign up boxes and chat bubbles you see on websites. Very often, the business owner pasted one small line of code into their site, and that code belongs to another company. Every message a visitor types travels across the internet into that company's database.
For my capstone project in the FlyRank Backend AI Engineering internship, I built one of these platforms from scratch: a lead capture widget that business owners can drop onto any website. And the hardest part was not making it work. It was making sure nobody could abuse it.
In this post, I will walk you through the five gates every submission has to pass through, in plain English. You will learn why I sometimes answer "not found" when the truth is "forbidden", why I lie to spam bots, and why my system happily saves a lead even when two of the services it depends on are completely down. If you have a contact form on your website, there is something here for you too.
What I built, and the three kinds of visitors
The platform works like this. A business owner signs in, creates a widget, and gets one line of code to paste into their website. The widget appears on their site as a form. When a visitor fills it in, the lead lands in the owner's dashboard.
I built the backend with Node.js, Express, TypeScript and a PostgreSQL database, packaged with Docker so the whole system starts with a single command. The code is public on GitHub.
The most important design decision came before any code. I realised the system has three very different kinds of visitors, and each one deserves a different level of trust:
- The owner. A signed in business owner managing their widgets and reading their leads. We know exactly who they are, because they prove it with a login.
- The customer's website. The page where the widget lives. It only needs to fetch the widget's settings, like the form title and which fields to show. It gets read only, public information and nothing else.
- The visitor. A stranger on someone else's website, sending a form submission into my database. I don't know who they are, where they are, or whether they are even human.
That third visitor changed how I thought about everything. Every other part of the system has a caller I can identify. The submission endpoint does not. The caller is the entire internet. So I trust nothing it sends.
Instead of one piece of code that receives a message and saves it, I built a chain of gates. A submission must pass through every gate before it is saved. And the gates follow one rule: reject cheaply, and reject early. The quickest, cheapest checks come first, so a bad request is turned away before it costs me anything expensive, like a database write or a call to another service.
Gate 1: Every owner sees only their own data
Before we even get to strangers, there is a quieter danger. Many business owners share the same platform and the same database. Each one is called a tenant, like tenants in one building. Owner A must never see Owner B's widgets or leads. Not by accident, not by guessing a link, not ever. This is called tenant isolation.
I built it on three habits.
Identity comes from the login, never from the request. When an owner asks for their widgets, the system does not ask "who do you say you are?". It reads their identity from their verified login token. If someone sneaks a different owner's ID into a request, the system simply ignores it.
Every database query is filtered by owner. Not most queries. Every single one. A request for a widget is really a request for "this widget, if it belongs to you".
When the answer is "not yours", I say "not found". This is my favourite decision in the whole project. Imagine Owner A tries to open a widget that belongs to Owner B. The honest answer is "forbidden", which developers call a 403. But a 403 quietly confirms something: this widget exists, you just can't have it. Repeat that enough times and an attacker can map out what exists on the platform.
So my system answers 404, "not found", exactly as if the widget never existed. Think of a good hotel receptionist. If you ask for the key to a room that isn't yours, they don't say "sorry, that room belongs to another guest". They say "I can't find a booking under your name". You learn nothing.
The same thinking applies to the public settings the widget fetches. The database stores private details about each widget, like who owns it and when it was created. The public endpoint sends back only a short, approved list of fields the form actually needs. Everything else stays inside.
Gate 2: CORS, the browser's guest list

Here is a problem that confuses almost every developer at some point. The form lives on a customer's website, say shop.com. But it sends its data to my server, on a completely different web address. Browsers don't allow that by default.
According to MDN, the web's main developer documentation, browsers follow a rule called the same origin policy: code on one website can only freely talk to its own web address, unless the other server explicitly allows it. The way a server gives that permission is called CORS, short for Cross Origin Resource Sharing.
For a form that sends data the way mine does, the browser first sends a polite little question before the real message. It is called a preflight. Roughly: "Hello server, a page on shop.com wants to send you a submission. Is that allowed?" Only if my server answers yes does the browser send the actual form data.
In my design, every widget has its own list of allowed websites, stored with its settings. The idea is that a widget created for shop.com should only accept submissions sent from shop.com.
Now two honest points.
First, CORS is a browser rule, not a lock on my server. Browsers obey it. A bot sending requests directly from a script does not use a browser at all, so CORS never stops it. The browser rules behind CORS exist to protect people while they browse. They do not protect my database from determined abusers. That is why it is only one gate of five.
Second, my capstone stopped one step short here. The allowed website list is stored for every widget, but in the version I submitted, the submission endpoint still accepted requests from any website, to keep testing simple. I documented this openly as a limitation, with enforcing each widget's list as the first change before real customers use it. I would rather tell you that than pretend it was finished.
Gate 3: Size limits and rate limits
The next two gates are simple, cheap and incredibly effective.
The size limit. A lead capture form needs a name, an email and maybe a short message. It never needs a novel. So my server refuses any submission bigger than 10KB before it bothers to process it, with an error that means "too large" (a 413). That stops anyone from flooding the server with giant messages designed to eat memory.
The rate limit. This one counts how many submissions arrive from each internet address in a short window of time. Stay under the limit, and you pass. Go over, and you get an error meaning "too many requests" (a 429), until the window resets. A real person filling in a contact form submits once, maybe twice. A bot trying to fill my database with junk submits hundreds of times. The rate limit tells them apart without needing to know who either of them is.
Where you place these gates matters as much as having them. I put the rate limiter right after the CORS check and before the server reads the message. Why? Because reading and checking a message costs computing effort, while checking a counter costs almost nothing. If someone floods the system, I want them turned away at the cheapest possible point, before they make my server do real work.
I tested this the satisfying way. For the test, I set the limit to just five submissions, then fired eight in a row from my terminal. The first five came back as successful. Requests six, seven and eight came back as "too many requests". Then I counted the rows in the database. It had grown by exactly five. The blocked requests never touched it.
I also used a well tested library for rate limiting instead of writing my own. Rate limiting looks simple but is easy to get subtly wrong, and knowing when to use proven tools is part of good engineering.
Gate 4: The honeypot, or how I lie to bots
After the size and rate checks, the submission is checked for the right shape: a real widget, the expected fields, sensible values. Anything malformed is rejected. Then comes my favourite trick in the whole system.
The form contains one extra field that real visitors never see. It is hidden from view. A human filling in the form leaves it empty, because they don't know it is there. But many spam bots work by filling in every field they find on a page. So when that hidden field arrives with something in it, I know a bot sent the submission.
This is called a honeypot, because it attracts exactly the visitors you want to catch.
Now here is the clever part. When the honeypot catches a bot, my server does not reply with an error. It replies with the exact same success message a real visitor gets. Then it quietly throws the submission away and saves nothing.
Why lie? Because an error teaches. If a bot gets "rejected: spam detected", the person who wrote it learns that the hidden field is a trap and adjusts. If the bot gets "success", its author believes the spam went through and has no reason to change anything. They learn nothing, and my database stays clean.
One caution if you add a honeypot to your own forms: hide the field from screen readers and keyboard users too, not just from eyes. Otherwise, a real person using assistive technology might fill it in and be silently ignored.
I use the same idea on the contact forms of both client websites I built, the oil and gas company and the French school. A honeypot is one of the cheapest, least annoying ways to cut down spam, because real visitors never have to solve a puzzle or click on pictures of traffic lights.
Gate 5: Degrade, don't fail
This last part is not about attackers. It is about something just as dangerous: things breaking on their own.
When a lead arrives, my system tries to add a little extra information, like the visitor's approximate country and city, based on their internet address. Business owners like knowing where their leads come from. To do this, my server asks an outside location service.
But I don't control that service. It can be slow. It can hit its own limits. It can be down for the day. So I asked myself a simple question: what matters more, the location or the lead?
The lead, every time. A lost customer is a real loss. A missing city name is a small inconvenience. So I designed the location lookup so that it can never break a submission:
- It asks a first location service, and gives up on it after 2 seconds.
- If that fails, it asks a second, completely separate service, also with a 2 second limit.
- If both fail, it stops trying and saves the lead without a location.
I made this decision even before writing the lookup. When I designed the database, I made the country and city columns allowed to be empty, because I already knew that one day they would be.
Then I tested it the brutal way. I deliberately broke both location services and sent a submission. My server's log showed the first service failing, then the second one failing too. And the visitor still got a success message, and the lead was saved, with the location simply left empty. Both services were dead, and not a single lead was lost.
The same rule covers the notification that tells the owner a new lead has arrived. In the capstone, that notification is simulated rather than a real email, but the important part is proven: if sending it fails, the lead is still saved. A broken email should never cost you a customer.
Proving it with tests
Saying a system is secure is easy. Proving it is the real work. So I wrote automated tests, small programs that send requests to the system and check that it responds correctly, every single time I run them.
The tests check promises such as these:
- An owner cannot read another owner's widget, and gets "not found" when they try.
- A submission that is too large is rejected.
- A flood of submissions gets "too many requests" after the limit.
- A submission caught by the honeypot gets a success reply but is never saved.
- A submission still succeeds when the location services fail.
To make the tests reliable, I replaced the outside services, like the location lookup and the login provider, with fake versions that behave exactly the way each test needs. That way, a test never fails just because a real service on the internet happened to be slow that day.
The tests also taught me a lesson I won't forget. At one point, tests that passed on their own started failing when run together. The cause: the rate limiter remembered how many requests it had seen, across tests. So one test's requests counted against the next test's limit. The fix was to wipe the limiter's memory before every test. The lesson goes beyond code: a fair test needs a clean start.
I also made sure no secret keys were ever saved in the public code, and wrote down every limitation of the project honestly, so anyone reviewing it knows exactly what is finished and what would come next. The capstone was reviewed and accepted, and I count it among the work I am proudest of.
What this means for the forms on your website
You may never build a widget platform. But if your website has a contact form, a quote request form or a newsletter sign up, the same dangers apply to you. Here is a checklist to go through with your developer:
- Is there spam protection? A hidden honeypot field is a great start. For forms that attract heavy spam, a light verification check can be added on top.
- Is there a limit on how often one person can submit? Without it, one bot can fill your inbox or database with thousands of junk messages in minutes.
- Is there a size limit on messages? Your form should refuse giant submissions politely instead of trying to process them.
- Is everything checked on the server, not just in the browser? Checks in the visitor's browser are helpful for real people, but bots skip them easily. The server must check again.
- What happens when something fails? If your email notification breaks, is the enquiry still saved somewhere? It should be. Ask where submissions go and whether you can see them even if emails stop.
- Does the form reveal anything it shouldn't? Error messages and public settings should never expose private details, email addresses of staff, or how your system works inside.
- If you use a form service, what protection does it include? Many form services handle spam filtering and rate limits for you. Ask which ones yours provides.
- Has anyone actually tested it? Try submitting it yourself, from a phone, with a long message and an empty message. Then check that the enquiry really arrived.
Security is a chain of small, boring decisions
When people hear "security", they imagine something dramatic: hackers in hoodies and green code raining down a screen. Real security is much quieter. It is a chain of small, boring decisions, each one closing a door.
Answer "not found" instead of "forbidden". Count requests and stop the flood early. Refuse messages that are too big. Set a trap that only bots fall into, and don't tell them they fell in. Make sure the thing that matters, the customer's message, survives even when everything around it breaks. Then write tests that prove each decision still holds tomorrow.
None of these gates is impressive on its own. Together, they turn a box on a stranger's counter into something a business can trust with its customers.
You can see the full project, its tests and its honest list of limitations on GitHub.
If you need a website or web application with forms, sign ups or an API that handles real customer data safely, send me an email at hello@chukwumaukaha.com or a message on WhatsApp. And if you already have forms that are drowning in spam, I would be glad to take a look.