A contact form is often the last thing you build, knocked out quickly, because "it's just a form." Three fields, a button, an email goes out. Except behind that button sits a public POST route, open to the internet, that triggers a server-side action: sending an email, through a service billed by usage. A target, in other words. Not the juiciest one on the web, but a target all the same, and far more exposed than the "about" page sitting right next to it.
This week I audited the one on this portfolio. I found a wide-open door that I'd installed myself months earlier, without realising it. I'll come back to it, it's the heart of the article. Before that, the point I want to land: a form's security doesn't fit in a single checkbox. It's a stack of small defences that, taken one by one, look trivial, and that hold together as a whole. The link that gives way, in my experience, is almost always a dev shortcut someone forgot to remove.
The reCAPTCHA widget protects nothing on its own
reCAPTCHA v3 has become a reflex. You add the provider on the client, the little badge shows up in the bottom right, and you feel covered. It's a comfortable illusion. The client widget generates a token, nothing more. As long as nobody verifies that token server-side, it's worthless: a bot doesn't even need to load Google's script, it posts straight to your endpoint with a bogus recaptchaToken field, or an empty one, or a copied one.
The real barrier is on the server. When the request comes in, you call Google's verification API back with your secret key, and you read what it returns: the success, the score (between 0 and 1, the higher it is the more likely the request is human), and the expected action. All three have to check out.
async function verifyRecaptchaV3(token: string, expectedAction = "contact_form") {
const secretKey = process.env.RECAPTCHA_SECRET_KEY;
if (!secretKey) return { isValid: false, score: 0 };
const response = await fetch("https://www.google.com/recaptcha/api/siteverify", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `secret=${secretKey}&response=${token}`,
});
const data = await response.json();
const isValid = data.success === true
&& data.action === expectedAction
&& data.score >= 0.5;
return { isValid, score: data.score ?? 0 };
}
The 0.5 threshold is the tuning knob. Too high and you block real visitors who are a bit jumpy on the click. Too low and you let bots through. 0.5 is a sensible middle for a brochure site. And checking the action matters as much as the score: it guarantees the token was actually issued for your contact form, not lifted from another page on the site.
The story of the magic token
Here's the open door. While building the form, I needed to test sending without dealing with reCAPTCHA on every local submission. Classic. So I'd added a small bypass: if the token received was the string "test_token_no_recaptcha", the server treated the captcha as valid and moved on. Handy in dev. The kind of crutch you promise yourself you'll remove before going to production.
Except it stayed. And not only that: the front-end was sending it all by itself. The client-side logic said, in effect, "if reCAPTCHA isn't available, send the fallback token anyway." And reCAPTCHA is unavailable far more often than you'd think: an ad blocker filtering Google's script, a privacy extension, a public key missing from an environment, a plain network hiccup at load time. In every one of those cases, a perfectly legitimate visitor's browser fell back to the magic token. And anyone glancing at the client code for two minutes saw the string sitting there in plain text.
The result: an attacker could post to /api/contact as much as they liked, without ever solving a single captcha, just by sending "test_token_no_recaptcha". The entire anti-bot layer, defeated by one line I'd written to make my own life easier six months earlier.
The fix is a reversal of logic. If reCAPTCHA isn't available, you don't bypass, you block. The server no longer knows any special token. The client, for its part, refuses to call the API and shows a clear message instead of inventing a free pass.
if (executeRecaptcha) {
recaptchaToken = await executeRecaptcha("contact_form");
} else {
// reCAPTCHA unavailable: block, don't send a fallback token
setSubmitError("Security check unavailable. Please refresh the page.");
return;
}
The lesson goes well beyond reCAPTCHA. A dev bypass left in production is a back door you installed yourself, documented, and then forgot existed. The worst vulnerabilities are almost never sophisticated attacks. They're convenience shortcuts nobody got around to removing. When I grep a project before a production release, test, debug, bypass, skip and TODO are the first words I look for.
Rate-limit first, before you even think
Say the captcha is solid. There's still a volume problem. With no cap, nothing stops someone from hammering your endpoint: a few thousand POSTs, and it's your email-sending quota draining away (Resend, in my case, bills by usage), your inbox overflowing, or your server spending its time calling Google's API for nothing.
A rate limit fixes this, and it has to run first of all, before validation, before the reCAPTCHA call, before any expensive operation. The idea: count requests per IP over a sliding window, and refuse beyond a threshold. For a brochure site, you don't need Redis or Upstash. An in-memory Map is plenty, five requests per ten-minute window per IP.
const rateLimitMap = new Map<string, number[]>();
const RATE_LIMIT_MAX = 5;
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
function checkRateLimit(ip: string): boolean {
const now = Date.now();
const windowStart = now - RATE_LIMIT_WINDOW_MS;
const recent = (rateLimitMap.get(ip) ?? []).filter((t) => t > windowStart);
if (recent.length >= RATE_LIMIT_MAX) {
rateLimitMap.set(ip, recent);
return false; // limit reached
}
recent.push(now);
rateLimitMap.set(ip, recent);
return true;
}
Two details that make the difference. The client's IP, behind a reverse proxy (and everyone is, in practice), isn't read off the socket: it's in the x-forwarded-for header, the first link in the list. If you take the direct connection's IP, you rate-limit your own proxy, which means everyone at once. Second point, yes, an in-memory Map empties on every redeploy, and doesn't survive across multiple instances. For a single-instance portfolio with modest traffic, that's a trade-off I'll take without blinking. Pulling in Redis for this would be engineering for the fun of it. On a high-traffic or multi-instance app, that's where the shared store becomes non-negotiable.
Never trust what comes in
Anything arriving from a form is hostile by default, until proven otherwise. In practice, two moves.
First, validate the shape. A Zod schema describes what you accept (minimum lengths, email format, required fields), and you reject everything else before touching anything. It's fail-fast applied directly: a non-conforming piece of data stops at the door, it doesn't wander into your business logic.
const contactSchema = z.object({
name: z.string().min(2),
email: z.string().min(1).email(),
subject: z.string().min(5),
message: z.string().min(10),
recaptchaToken: z.string().min(1),
});
Second, escape on the way out. My server sends me an HTML notification email for every message, with the name and the content interpolated into it. If I drop those fields raw into the HTML, I open up an injection: someone puts an <a> tag or an <img> in the "message" field, and my notification email ends up with a fake link or an image pulled from anywhere. Not the drama of the century, but it's my inbox turning into an attack surface. The countermeasure is trivial, escape the five characters that matter before any interpolation.
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
Validate what comes in, escape what goes out. Same rule as old as the web, applied to a channel we often forget to treat as one: an email rendered as HTML is a web page like any other.
Secrets have no business in the browser
Last point, and this one's more a plumbing trap than a vulnerability as such. With Next.js, any environment variable prefixed NEXT_PUBLIC_ gets inlined into the JavaScript bundle shipped to the client. Visible to anyone who opens the dev tools. The reCAPTCHA public key is made for exactly that, no problem. But the reCAPTCHA secret key and the email-sending API key have no business there. They're read at runtime, server-side only, and stay without the NEXT_PUBLIC_ prefix. Mixing up the two means publishing your secret key for all to see, and handing someone the ability to send emails in your name. The kind of mistake a secret scan in CI catches, and that's exactly why you put one there.
None of these layers, on its own, makes a form "safe." The server captcha without rate-limiting lets the flood through. The rate limit without validation lets the junk through. The validation without escaping lets the injection through. It's the stacking that does the work, and the hard part isn't writing them, it's thinking of all of them, and not sabotaging the whole thing with a forgotten dev crutch. The most ordinary form on a site is often the one that most deserves an audit, precisely because nobody's looking at it.
If you want an outside eye on yours, let's talk.
Top comments (0)