Tutorials

How to prevent spam in contact forms

January 6, 20267 min read
How to prevent spam in contact forms

If you have a contact form that's been public for more than a few weeks, you've probably gotten spam through it. Not because someone specifically targeted you, but because bots systematically crawl the web, find forms, and submit them.

Some of it is obvious: garbled text, links to pharmaceutical sites, messages in languages you don't speak. Some is more subtle: plausible-sounding messages with a suspicious link buried in them. Either way, it's noise in your inbox, and it gets worse over time.

The good news: there are practical, low-effort defenses that stop the vast majority of form spam without adding friction for real users.

How form spam works

Before picking a defense, it helps to understand what you're defending against.

Bots. The most common source. Automated scripts crawl pages, find <form> elements, fill in every visible field with garbage or pre-written content, and submit. They're not smart. They just run the same pattern over and over. Most spam comes from here.

Scraped address targeting. Your email shows up on your site, gets scraped, and ends up in a list. Then a human (or less sophisticated bot) uses your contact form to reach you. This is harder to filter because the submissions look more human.

Spam-as-a-service. Some spam is submitted by real humans paid fractions of a cent per submission. These bypass most automated bot detection.

The techniques below work well against bots and reasonably well against the second category. The third is harder, but also rarer.

Technique 1: Honeypot fields

A honeypot is a hidden form field that real users never see or fill, but bots usually do, because they fill every field they can find.

The idea: if a submission includes that field, discard it.

<form action="https://formtorch.com/f/YOUR_FORM_ID" method="POST">
  <!-- Visible fields -->
  <input name="name" type="text" required />
  <input name="email" type="email" required />
  <textarea name="message" required></textarea>

  <!-- Honeypot: hidden from real users, filled by bots -->
  <input
    name="_honeypot"
    type="text"
    tabindex="-1"
    autocomplete="off"
    aria-hidden="true"
    style="position: absolute; left: -9999px;"
  />

  <button type="submit">Send</button>
</form>

A few implementation details that matter:

Hide it with CSS, not type="hidden". Bots know type="hidden" fields are not user-facing and often skip them. CSS-hidden fields look like real inputs to a bot's HTML parser.

tabindex="-1" prevents keyboard users from accidentally tabbing into it.

autocomplete="off" prevents password managers from offering to fill it.

Honeypots are effective against simple bots and have zero user-visible cost. Start here.

Tip

If you're using Formtorch, honeypot support is built in. Name the field _honeypot (or configure a custom field name via _honeypotField) and Formtorch handles the rest. No server code needed.

Technique 2: Rate limiting

Rate limiting caps how many submissions a single IP address can make in a given window. A real user submitting a contact form might do it once, maybe twice if they think it failed. A bot might try thousands of times.

A simple rule: allow 3 submissions per IP per hour, then return an error.

If you're building a custom backend, here's a rough pattern in Node.js:

// Simple in-memory rate limiter (use Redis for multi-instance deployments)
const submissions = new Map<string, { count: number; resetAt: number }>();

function isRateLimited(ip: string): boolean {
  const now = Date.now();
  const window = 60 * 60 * 1000; // 1 hour in ms
  const limit = 3;

  const record = submissions.get(ip);

  if (!record || record.resetAt < now) {
    submissions.set(ip, { count: 1, resetAt: now + window });
    return false;
  }

  if (record.count >= limit) return true;

  record.count++;
  return false;
}

// In your form handler:
export async function POST(req: Request) {
  const ip = req.headers.get("x-forwarded-for") ?? "unknown";

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

  // process the submission
}
Note

In-memory rate limiting doesn't survive server restarts and doesn't work across multiple instances. For anything in production, use Redis (Upstash is a good hosted option) or a similar persistent store.

Formtorch applies multi-layer rate limiting at the platform level (per-IP, per-form, and globally), so you don't need to implement this yourself if you're using a form backend.

Technique 3: CAPTCHA

CAPTCHA is the oldest and most well-known form spam defense. The idea: show a challenge that's easy for humans and hard for bots.

The problem with traditional CAPTCHAs (distorted text, image grids) is that they also make things harder for real users. Accessibility is genuinely compromised, especially for users with visual impairments. Completion rates drop. It can feel like you're treating your visitors as suspects.

Modern invisible CAPTCHAs (Cloudflare Turnstile, hCaptcha invisible, Google reCAPTCHA v3) are much better. They run in the background and only show a challenge if the risk score is high enough.

<!-- Cloudflare Turnstile (invisible mode) -->
<script
  src="https://challenges.cloudflare.com/turnstile/v0/api.js"
  async
  defer
></script>

<form action="https://formtorch.com/f/YOUR_FORM_ID" method="POST">
  <input name="name" type="text" required />
  <input name="email" type="email" required />
  <textarea name="message" required></textarea>

  <!-- Turnstile widget: generates a token in cf-turnstile-response -->
  <div
    class="cf-turnstile"
    data-sitekey="YOUR_SITE_KEY"
    data-theme="auto"
  ></div>

  <button type="submit">Send</button>
</form>

The token in cf-turnstile-response needs to be verified server-side before you accept the submission. That's where it gets involved. You need a server (or a form backend that supports Turnstile verification).

When to use CAPTCHA: When honeypots and rate limiting aren't enough. For most contact forms, they are. Reserve CAPTCHA for high-volume forms or situations where you're seeing consistent bot activity that honeypots don't catch.

Technique 4: Spam keyword filtering

Many bot submissions contain predictable content: Viagra, casino links, SEO pitches, certain URLs. You can reject submissions that match known patterns.

const SPAM_PATTERNS = [
  /\b(viagra|cialis|casino|lottery|prize|winner)\b/i,
  /https?:\/\/[^\s]+\.(ru|cn|tk|ml)\b/,
  /<a\s+href/i, // HTML links in messages
  /\[url=/i, // BBCode links
];

function isSpamContent(text: string): boolean {
  return SPAM_PATTERNS.some((pattern) => pattern.test(text));
}

Keyword filtering catches naive bots but has a real false-positive risk. Someone could legitimately mention a medical topic, or link to a .ru site for a valid reason. Use it as one signal in a scoring system, not a binary pass/fail.

How a scoring system works

Rather than blocking on any single signal, the most robust approach combines multiple signals into a score. Each signal contributes points; if the total crosses a threshold, the submission is treated as spam.

SignalWhat it catchesFalse positive risk
Honeypot filledSimple bots that fill all fieldsNear zero
Submission too fast (<2s)Automated submissionsLow
Spam keyword matchTemplate-based bot contentMedium
Duplicate submissionRepeated identical payloadsLow
Suspicious IP / rate exceededHigh-volume bot campaignsLow

Store spam submissions rather than discarding them, at least initially. This gives you a way to verify the filtering is working correctly and recover anything that was wrongly flagged.

Warning

Never silently discard submissions without confirmation to the user. If you're unsure whether something is spam, let it through and filter it manually. A missed legitimate message is worse than a spam message in your inbox.

What Formtorch does for you

If you're using Formtorch, all of this is handled at the platform level. The built-in TorchWarden system scores every submission across five signals:

  • Honeypot detection (native _honeypot field support)
  • Behavioral signals: submission speed and pattern analysis
  • Keyword pattern matching across message content
  • Duplicate detection via Redis (same payload, same form, within a time window)
  • Rate limiting: per-IP and per-form, with in-memory fallback

Spam submissions are stored in your dashboard with a spam flag and score, so you can review them if needed. They don't count against your quota and don't trigger email notifications.

You can also enable a custom honeypot field with a name specific to your form, which catches bots that have learned to skip generic _honeypot field names.

Putting it together

The right level of protection depends on your situation:

Low-traffic contact form on a personal site: honeypot + Formtorch's built-in scoring is almost certainly enough.

Contact form on a public-facing product or marketing site: honeypot + rate limiting + keyword scoring. Formtorch handles all of this automatically.

High-volume form (newsletter, waitlist, etc.): consider adding Turnstile or similar invisible CAPTCHA on top of the above.

A form that's currently getting hammered: start with rate limiting (it stops volume attacks immediately), then work backwards to figure out which other signals you can add.

The most common mistake is starting with CAPTCHA because it feels like the most thorough defense. It's also the most costly for user experience. Try the lighter options first. For most contact forms, a honeypot field and rate limiting is genuinely all you need.

Want spam protection without building it yourself?

Formtorch's TorchWarden runs on every submission, automatically. Set up your form in under two minutes.

Related posts