Security

Best ways to secure a form endpoint

January 21, 20267 min read
Best ways to secure a form endpoint

A contact form looks harmless. It's just a name, an email, and a message. But the endpoint that receives submissions is one of the most exposed surfaces on your site. It accepts arbitrary user input from the public internet, processes it, stores it, and triggers notifications. That's a meaningful attack surface.

Security here isn't about building a fortress. It's about layering practical defenses that close the obvious gaps. Most vulnerabilities in form endpoints come from the same handful of mistakes, and most of them are straightforward to fix.

HTTPS everywhere, no exceptions

This one is table stakes and mostly solved by modern hosting, but worth stating clearly: your form's action URL must use HTTPS.

A form submitted over HTTP sends all field data in plaintext. Anyone on the same network can read it. This matters especially for contact forms because they often collect email addresses and sometimes phone numbers or other personal details.

Redirect HTTP to HTTPS at the server or CDN level. Don't give users the option to submit over HTTP. Most hosting platforms handle this by default, but verify it in your network tab the first time you test a form in production.

Validate everything on the server

Client-side validation is good UX. It gives users immediate feedback without a round trip. But it provides zero security.

Anyone can submit a POST request directly to your endpoint, skipping your frontend entirely. With curl, you can hit any form endpoint in one line:

curl -X POST https://yoursite.com/api/contact \
  -F "email=notanemail" \
  -F "message=$(python3 -c 'print("A" * 1000000)')"

Server-side validation needs to re-check everything independently: required fields, valid email format, message length limits, allowed file types. Don't assume the frontend did this correctly. Don't assume the request came from your frontend at all.

function validateSubmission(data: FormData): string | null {
  const email = String(data.get("email") ?? "").trim();
  const message = String(data.get("message") ?? "").trim();

  if (!email) return "Email is required.";
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return "Invalid email format.";
  if (!message) return "Message is required.";
  if (message.length > 10_000) return "Message too long.";

  return null;
}

Length limits matter more than they look. An endpoint that accepts arbitrarily long messages can be used to fill your storage with junk, slow down email delivery, or trigger resource exhaustion.

Configure CORS intentionally

CORS tells the browser which origins are allowed to make requests to your endpoint. If you don't configure it, the browser's default policy applies, and cross-origin requests from JavaScript (fetch, XMLHttpRequest) are blocked.

The trap: developers sometimes solve CORS errors by setting Access-Control-Allow-Origin: *, which allows requests from any origin. For a contact form endpoint, this is almost always wrong.

const ALLOWED_ORIGINS = ["https://yoursite.com", "https://www.yoursite.com"];

function corsHeaders(origin: string | null): HeadersInit {
  const allowed =
    origin && ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0];
  return {
    "Access-Control-Allow-Origin": allowed,
    "Access-Control-Allow-Methods": "POST, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type, X-Requested-With",
  };
}

Note that CORS only restricts browser-based requests. It does not prevent direct API calls from servers, scripts, or tools like curl. CORS is a browser enforcement mechanism. It's useful for preventing other websites from using your endpoint in their pages, but it's not a substitute for authentication or rate limiting.

Rate limit by IP

Rate limiting caps how many requests a single source can make in a given window. It's your primary defense against automated abuse, whether that's a spam bot flooding your form or someone trying to enumerate valid emails.

// Using Upstash Redis for multi-instance compatibility
import { Redis } from "@upstash/redis";
import { Ratelimit } from "@upstash/ratelimit";

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, "1 h"),
});

export async function POST(req: Request) {
  const ip = req.headers.get("x-forwarded-for") ?? "anonymous";
  const { success } = await ratelimit.limit(ip);

  if (!success) {
    return new Response("Too many requests", { status: 429 });
  }

  // proceed with submission
}

The right limit depends on your form's legitimate usage. A contact form on a personal site realistically sees one submission per person. Five per hour per IP is generous. Adjust based on your actual traffic patterns.

Note

In-memory rate limiting (a plain Map or object in your process) does not work in serverless environments or across multiple instances. Use Redis or a similar persistent store. Upstash offers a generous free tier that works well for this.

Protect against spam and bot submissions

Spam protection and security overlap significantly here. Bots that submit junk data are both a spam problem and an abuse problem, and the defenses are the same: honeypot fields, behavioral scoring, and duplicate detection.

The most important thing from a security standpoint is that spam defenses run server-side, not client-side. A JavaScript-only honeypot check can be bypassed by anyone who reads your source. The check has to happen in your handler before you accept the submission.

For a detailed breakdown of how spam attacks work and how to defend against them, see How to prevent spam in contact forms and Why contact forms get spam.

Sanitize before storing and displaying

When you store a form submission in a database and then display it in a dashboard, you have an XSS vector if you're not careful. If a submission contains <script>alert('xss')</script> and you render it with innerHTML or an unsafe template, that script runs for anyone who views the submission.

The fix is context-dependent:

In the database: store raw input. Don't sanitize before storing; you want to preserve the original submission and let your rendering layer handle safety.

In HTML rendering: use your framework's safe rendering methods. In React, {submission.message} is safe because JSX escapes by default. dangerouslySetInnerHTML is not safe with user input. In plain HTML templating, escape <, >, &, ", and ' before inserting user content.

In email notifications: email HTML has its own rules. Most email sending libraries handle basic escaping, but double-check that user-submitted content is escaped before it goes into your email template.

In a database query: use parameterized queries or an ORM that does this automatically. Never concatenate user input directly into SQL strings.

// Wrong
const query = `SELECT * FROM submissions WHERE email = '${userEmail}'`;

// Right (with a parameterized query)
const result = await db
  .select()
  .from(submissions)
  .where(eq(submissions.email, userEmail));

Be careful with redirect parameters

If your form endpoint accepts a _redirect parameter to send users to a custom success page, validate that the redirect target is a URL you actually own. An open redirect lets an attacker craft a URL like:

POST /contact
_redirect=https://malicious-phishing-site.com

Someone who clicks a link to your domain ends up on a phishing site. Browsers show your domain in the URL before the redirect completes, which is enough to make the attack convincing.

const ALLOWED_REDIRECT_HOSTS = ["yoursite.com", "www.yoursite.com"];

function isSafeRedirect(url: string): boolean {
  try {
    const parsed = new URL(url);
    return ALLOWED_REDIRECT_HOSTS.includes(parsed.hostname);
  } catch {
    return false;
  }
}

If you're using a form backend service, this is handled for you. If you're building your own, validate redirect targets server-side before following them.

Don't leak information in error responses

Error responses tell you what went wrong. They can also tell an attacker what to try next.

A response like {"error": "No user found with email user@example.com"} confirms that email addresses are stored in your system. A response like {"error": "Database connection failed at host db.internal:5432"} leaks your internal infrastructure details.

Keep error messages general to the outside world. Log the specific error server-side where you can review it, but return something generic to the client:

try {
  await processSubmission(data);
  return Response.json({ ok: true });
} catch (error) {
  console.error("Submission processing failed:", error);
  return Response.json(
    { error: "Something went wrong. Please try again." },
    { status: 500 }
  );
}

Protect access to stored submissions

If you have a dashboard that shows submission history, make sure it's behind authentication. A public URL that lists every contact form submission is a significant data leak, especially if submissions include phone numbers, addresses, or other personal details.

This one is easy to overlook because the dashboard is "for internal use." That's precisely why it needs protection. An unauthenticated internal tool is an accidentally public one.

The form backend option

If you're using a hosted form backend, most of this is handled at the platform level: HTTPS enforcement, rate limiting, spam filtering, open redirect protection, and secure storage are built in. The security posture you'd otherwise build and maintain yourself comes with the service.

That's worth factoring into your build-vs-buy decision. Building a secure form endpoint is genuinely achievable, but it's a real project. The complexity involved is mapped out in The hidden complexity of handling form submissions.

Want a secure form endpoint without building one?

The Formtorch form backend handles HTTPS, rate limiting, spam filtering, and secure storage on every submission.

Related posts