consolelog.tools
Back to Blog
Share:
Public Form APIs: What You Can and Can't Trust From the Browser
GuideJune 8, 202614 min read

Public Form APIs: What You Can and Can't Trust From the Browser

Your form is a suggestion; your endpoint is the contract. A practical guide to securing contact forms, newsletters, comments, and feedback endpoints: server-side validation, email header injection, honeypots and rate limiting, anchored origin checks, client-supplied identity, and double opt-in.

Mohammed Banani

Mohammed Banani

Author14 min read

0

Claps

Public Form APIs: What You Can and Can't Trust From the Browser

A contact form feels finished the moment the UI works. You typed a name, a real email, and a message; the button spun; a success toast appeared; an email landed in your inbox. Ship it.

But the form you built is only one of the clients that will ever talk to your endpoint. The other clients are curl, a ten-line Python script, a headless browser farm, and a bot that found /api/contact in your page source about thirty seconds after you deployed. None of them will run your React validation, trip your honeypot, or wait for your submit button to enable. They will POST whatever JSON they like, as fast as they like, from wherever they like.

The reframe that saves you is this: your form is a suggestion, your endpoint is the contract. Every field that reaches the server is attacker-controlled, including the fields your UI never lets a human edit. This guide walks through what that means for the public endpoints almost every app ships (contact forms, newsletter signups, comments, feedback widgets) and the handful of checks that separate "the happy path works" from "this survives contact with the open internet."

The shape of the problem is similar across all of them, even though the specific abuse differs:

Endpoint What it gets abused for The defense that matters most
Contact form Email header injection, spam relay Sanitize header-bound fields; honeypot + rate limit
Newsletter signup List bombing, planting fake subscribers Double opt-in; tight per-IP rate limit
Comments Stored spam (and XSS, if you render raw) Escape on render; schema validation + spam scoring
Feedback / votes Metric inflation Server-derived dedup key; rate limit

The rest of this guide is the toolkit behind that last column.

Client-Side Validation Is UX, Not Security

The required attributes, the email regex in your onChange, the disabled button until the form is valid: all of that is real and worth doing. It gives a human immediate, friendly feedback. It is also completely irrelevant to your security, because the attacker never loads your JavaScript.

JavaScript
// What your form sends:
{ "email": "[email protected]", "message": "Loved the site!" }

// What a script sends to the exact same endpoint:
{ "email": {"$ne": null}, "message": "x".repeat(5_000_000), "isAdmin": true }

If the only thing standing between those two requests is the form component, the second one wins. So the rule is blunt and non-negotiable: whatever you validate in the browser, validate again on the server, as if the browser never ran. The client check is for the user. The server check is for everyone else.

The clean way to do this is a single schema that both sides import, so the rules can't drift. A library like Zod lets you define the shape once and safeParse untrusted input at the API boundary. If you already keep a JSON Schema for your payloads, JSON Schema to Zod will turn it into a validator you can drop straight into the route.

Validate Like You Mean It

"Validate" is doing a lot of work in that last sentence, so let's be specific. A server-side validator for a public endpoint has four jobs.

Check the type, not just the truthiness. if (body.email) passes for the string "[email protected]" and also for the object { "$ne": null }. Against some databases that object is a query operator that matches every row. Coerce or assert the type before you do anything else: typeof body.email === 'string', or better, let the schema reject anything that isn't the type you expect.

Cap every length. A TEXT column will happily store a five-megabyte comment, and a bot will happily send one a thousand times. Length limits are storage-abuse limits. Cap the name, the subject, the message, the email, every free-text field, at the boundary, before it touches the database, so a direct caller can't blow past whatever your textarea's maxLength said.

Normalize before you store. Lowercase the email, trim the whitespace, collapse the runs of spaces. Normalization is what makes "already subscribed" checks and per-user uniqueness actually work, instead of treating [email protected] and [email protected] as two different people.

Constrain the format. An email field should hold an email. A URL field should hold an http(s) URL and nothing else. Otherwise it's just a free text field that an attacker fills with a javascript: payload or a spam link you then echo into a notification. Build and test these patterns interactively with a Regex Tester before you trust them in production; an email regex that looks right and quietly accepts a@b will bite you later.

The Header-Injection Trap

This one is specific to contact forms and any feature that turns user input into an email, and it is the bug people are most surprised by.

When a user-supplied field becomes part of an email (the subject line, a display name, a reply-to), it becomes part of the message's headers. Headers are separated by newlines. So if you take a subject straight from the form and an attacker puts a newline in it, they can inject headers you never intended:

Text
Subject: Quick question
Bcc: [email protected], [email protected], ...

A trimmed-but-not-sanitized subject of Quick question\nBcc: [email protected] can, against a naive mail path, turn your contact form into an open relay that BCCs strangers. The same trick smuggles fake Content-Type or From headers.

The fix is to treat any field destined for a header as single-line by definition. Strip control characters (the C0 range, which includes carriage return and line feed) and collapse what's left:

JavaScript
// Anything that becomes an email header (subject, name, reply-to) gets this.
function singleLine(value) {
  let out = '';
  for (const ch of value) {
    const code = ch.codePointAt(0);
    out += code < 0x20 || code === 0x7f ? ' ' : ch; // drop CR/LF & friends
  }
  return out.replace(/\s{2,}/g, ' ').trim();
}

The same principle applies anywhere user input crosses into a structured protocol: log lines (log injection), HTTP response headers, CSV cells (formula injection). The boundary between "data" and "instructions" is exactly where injection lives, and a newline is the most common way across it.

Spam Doesn't Knock

A public POST endpoint with no authentication is the most-probed surface you own. The spam that hits it is not a person. It's a script that fills every field it can see and submits instantly. Three cheap, layered defenses stop most of it without adding a single click for real users.

A honeypot. Add a field that's hidden from humans (offscreen, aria-hidden, tabindex="-1") and leave it empty. Bots fill every input they find, so a non-empty honeypot is a near-certain bot. The subtle part is how you reject it: return the same 200 a real success returns. If a tripped honeypot gets a 400, the bot learns the field exists and strips it next time. Silence teaches it nothing.

A time-trap. Stamp when the form was rendered, check how long it took to submit. A human takes seconds to read and fill a contact form; a bot replays the request in milliseconds. Drop anything implausibly fast, silently, same as the honeypot.

Content heuristics. A message that's mostly links, or carries <a href> / BBCode markup, or repeats the same keyword twenty times, is spam in a way that's easy to score conservatively. Be cautious here, because a false positive eats a real person's message, so lean on strong signals and, when something looks spammy, prefer holding it for review over deleting it outright.

None of these are bulletproof alone. Stacked, with the cheapest checks first, they're a wall that bots mostly bounce off.

Rate Limiting Is Not Optional

Even with perfect validation, an unthrottled endpoint is a denial-of-wallet and denial-of-inbox waiting to happen. Someone will eventually point a loop at it and send fifty thousand requests, and without a limiter every one of them runs your full handler: database writes, outbound email, the lot.

A per-IP sliding window is the baseline: N requests per window, keyed on the client IP, rejected with the correct status code once exceeded. That code is 429 Too Many Requests, not 403, and not a silent 200. The right status lets honest clients back off and lets you see the abuse in your logs.

Two details people miss. First, the client IP behind a proxy or CDN lives in X-Forwarded-For, and you want the first hop, not req.socket.remoteAddress (which is your proxy). Second, if you'd rather not store raw IPs for privacy reasons, key the limiter on a hash of the IP instead. A Hash Generator shows exactly what SHA-256 does to an address, and a truncated hash is plenty to bucket requests without retaining the original.

JavaScript
// Reject with the right code; log it so abuse is visible.
if (overLimit(key)) {
  return Response.json(
    { error: 'Too many requests, please slow down.' },
    { status: 429 },
  );
}

In-memory limiters are fine for low volume and a single warm instance, but they reset on cold starts and don't share state across regions. The moment you're serious, move the counter to something durable (a KV store, Redis, or a Durable Object) so the limit holds everywhere at once.

Cross-Site POSTs and the Origin Check

Your form lives on your domain, but your endpoint will accept a POST from anyone's domain unless you say otherwise. A genuine browser fetch from your own page always carries a same-origin Origin header, which gives you a cheap filter: if the Origin is a different site, it's cross-site abuse, and you can drop it.

The trap is in how you compare hosts. String matching an allowlist is exactly where people slip:

JavaScript
// Looks fine. Is not fine.
if (host.startsWith('127.0.0.1')) return true;  // also matches 127.0.0.1.evil.com
if (host.endsWith('trusted.com')) return true;  // also matches eviltrusted.com

// Anchored. An attacker can't pad the match with their own domain.
if (host === '127.0.0.1' || host.startsWith('127.0.0.1:')) return true;
if (host === 'trusted.com' || host.endsWith('.trusted.com')) return true;

An unanchored startsWith/endsWith lets an attacker register a domain that contains your trusted string and sail through. Host checks want exact equality or a label-boundary suffix (note the leading dot), never a raw substring.

If your endpoint is genuinely meant to be called from other origins, that's what CORS is for, and CORS deserves the same care. A wildcard Access-Control-Allow-Origin: * combined with credentials is a classic mistake. A CORS Header Generator is a quick way to produce a policy that allows the origins you mean and no more, rather than copy-pasting the permissive example off the first search result.

Don't Trust Client-Supplied Identity

Public endpoints love to dedupe on something the client sends: a sessionId in the body for "one vote per user," a userId for "your data only," an idempotency key. The problem is that all of those are attacker-controlled, and treating them as identity hands the attacker a dial.

A concrete version: a "one clap per session" feature keys a unique constraint on a client-supplied sessionId. An attacker sends a fresh random sessionId on every request and inflates the count without limit. Worse, if they send a null session, many databases treat NULL as distinct in a unique index, so the "one per session" guarantee silently evaporates and every request inserts a new row.

The fix is to derive the identity server-side. Fall back to a value the client can't freely rotate (the IP, or a hash of it), and pair it with the rate limit you already have:

JavaScript
// Never let a missing/rotating client id defeat the per-user constraint.
const dedupeKey = body.sessionId?.trim() || `ip:${clientIp(req)}`;

When you do need to mint identifiers (request IDs, idempotency keys, anything that ends up in a database), generate them on the server with something unguessable. A UUID Generator or a Nano ID generator gives you collision-resistant IDs; sequential integers, by contrast, invite enumeration (the classic /orders/1, /orders/2 walk). The same logic that protects auth tokens applies here: if it identifies something, it shouldn't be guessable, and it shouldn't be the client's to choose.

Newsletters: Consent Is a Security Property

Signup forms get treated as harmless, but a newsletter endpoint that subscribes any address it's handed is its own kind of vulnerability. Without protection, anyone can submit a victim's email a thousand times, and now you are the one sending unwanted mail to a stranger: a "list bombing" attack that uses your good sending reputation as the weapon.

Two things keep a signup form honest:

Double opt-in. Don't mark an address as confirmed because someone typed it into a box. Store it as unconfirmed, send a confirmation link with a one-time token, and only activate the subscription when that link is clicked. It proves the person on the form actually controls the inbox. It also keeps your list clean, which is what keeps you out of spam folders.

Rate limiting and origin checks, same as everywhere else. Subscribing is a once-in-a-blue-moon action for a real visitor, so a tight per-IP limit costs them nothing and stops mass insertion cold.

And because a newsletter only works if the mail actually arrives, the deliverability basics are part of the security story too: SPF, DKIM, and DMARC records authenticate your domain so mailbox providers trust your sending, and a real unsubscribe link in every email is both the law in most places and the difference between a complaint and a quiet opt-out.

Secrets, Errors, and What Leaks Back

The last category is what your endpoint gives away when something goes wrong.

Don't leak internals in error responses. When your mail provider or database throws, log the detail server-side and return a generic message to the client. A stack trace or a raw provider error in the JSON response hands an attacker a map of your internals: library versions, table names, file paths.

JavaScript
catch (err) {
  console.error('[contact] send failed:', err); // detail stays in your logs
  return Response.json({ error: 'Failed to send message.' }, { status: 502 });
}

Keep secrets on the server. API keys, database URLs, signing secrets, and webhook secrets belong in server-only environment variables, never in client-shipped code or NEXT_PUBLIC_* vars. It's easy to leak one by accident in a refactor; a Secret Scanner is a fast sanity check on a diff or a file before it ships.

Generate strong secrets, don't type them. Any shared secret, like a webhook signing key or a confirmation-token seed, should come from a CSPRNG with real entropy, not a passphrase someone invented. A Password Generator set to a long random string is a perfectly good source for one. If your endpoint also verifies signed tokens, the same "verify, don't just decode" discipline from JWT decoding and verification applies: a valid-looking token is not a verified one.

A Short Checklist

Before you ship a public form endpoint, walk this list:

  • Every field is validated server-side with a schema, not just in the browser.
  • Types are asserted (no object-where-a-string-belongs), and every free-text field has a length cap.
  • Email/URL fields are format-constrained; emails are normalized before storage.
  • Any field that becomes an email or HTTP header is stripped of CR/LF (no header injection).
  • A honeypot and/or time-trap is in place, and tripping it returns a normal 200, not a 400.
  • The endpoint is rate-limited per IP and rejects with 429, with abuse logged.
  • Cross-site POSTs are filtered by an anchored origin/host check (or a deliberate CORS policy).
  • Dedup and ownership keys are derived server-side, never trusted from the client body.
  • Newsletter signups use double opt-in; confirmation tokens and unsubscribe links exist.
  • Errors return generic messages; secrets live only in server-side env vars.

None of this is exotic, and most of it is a few lines per route. The mistake is almost never a missing firewall or an exotic exploit. It's a public endpoint that trusted its own form, because the happy path worked and a passing demo felt like proof. It isn't proof until it holds up against a client that ignores every rule your UI tried to enforce.

Tags

securityapibackendspamvalidationrate-limitingweb-security

Tools used in this post

Free and in-browser. Try what you just read about.

7 tools

Join Other Developers

Get weekly tutorials, tool releases, and developer tips delivered straight to your inbox.

No spam, just new tools and guides when they ship. Unsubscribe anytime.

Try our developer tools

Explore 300+ free online tools for developers. No installation, no registration, works offline.

Browse All Tools