DEV Community

Cover image for The 3-Layer Trick That Completely Killed My Spam Problem
Suman Kshetri
Suman Kshetri

Posted on

The 3-Layer Trick That Completely Killed My Spam Problem

A few days ago I learned an uncomfortable lesson about leaving public forms unprotected on the internet.

My portfolio contact form got absolutely destroyed by bots.

Not exaggerating — ~400 spam emails in about 3 minutes.

My inbox looked like a denial-of-service attack, but for email. Every message was some variation of:

"Johndoe..."
"Hello..."

You know the type.

At first I thought: "Okay… maybe just a few bots."

Then the notifications kept coming.

So I did what every developer eventually does — I built a fix.


Why Does This Happen?

Any public form on the internet will eventually be targeted. Bots continuously crawl the web looking for:

  • Contact forms
  • Email endpoints
  • Comment sections
  • Signup pages

If your form has no protection between the user and your email server, you're essentially leaving the front door wide open.

The fix? Layer multiple lightweight protections. No single check is bulletproof, but together they make your form extremely hard to abuse.


Layer 1 — Honeypot 🍯

A honeypot is a hidden form field that real users never see or interact with. Bots, however, tend to fill out every field they find — so if it has a value, you know it's a bot.

Frontend (React/JSX):

<input
  type="text"
  name="honeypot"
  style={{ display: "none" }}
  tabIndex={-1}
  autoComplete="off"
/>
Enter fullscreen mode Exit fullscreen mode

Backend check:

export function isBot(honeypot: unknown): boolean {
  return typeof honeypot === "string" && honeypot.trim().length > 0;
}
Enter fullscreen mode Exit fullscreen mode

The trick: if a bot triggers it, return a fake success instead of an error. Bots retry on errors — silence keeps them away.

if (isBot(honeypot)) {
  // Don't tell the bot it failed
  return Response.json({ success: true }, { status: 200 });
}
Enter fullscreen mode Exit fullscreen mode

Layer 2 — Rate Limiting 🚦

Even if a bot slips past the honeypot, it might hammer your endpoint with dozens of requests per second. Rate limiting caps how many requests a single IP can make in a given window.

const rateLimitMap = new Map<string, { count: number; resetAt: number }>();

export function isRateLimited(
  ip: string,
  maxRequests = 3,
  windowMs = 60_000, // 1 minute
): boolean {
  const now = Date.now();
  const entry = rateLimitMap.get(ip);

  if (!entry || now > entry.resetAt) {
    rateLimitMap.set(ip, { count: 1, resetAt: now + windowMs });
    return false;
  }

  if (entry.count >= maxRequests) return true;

  entry.count++;
  return false;
}
Enter fullscreen mode Exit fullscreen mode

This allows 3 requests per IP per minute. If exceeded, return a 429:

if (isRateLimited(ip)) {
  return Response.json(
    { success: false, error: "Too many requests. Please wait a minute." },
    { status: 429 }
  );
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Note: This in-memory approach resets on server restart. For production, use a distributed store like Redis or Upstash.


Layer 3 — Google reCAPTCHA 🤖

The final gate. Even if a bot bypasses the honeypot and rotates IPs to beat rate limiting, it still needs to pass Google's reCAPTCHA verification.

Frontend — load the reCAPTCHA script and get a token on submit:

// Add to your <head>
<script src="https://www.google.com/recaptcha/api.js?render=YOUR_SITE_KEY" />

// In your submit handler
const token = await grecaptcha.execute("YOUR_SITE_KEY", { action: "contact" });

// Send token with your form data
await fetch("/api/contact", {
  method: "POST",
  body: JSON.stringify({ name, email, message, honeypot, recaptchaToken: token }),
});
Enter fullscreen mode Exit fullscreen mode

Backend — verify the token with Google:

export async function verifyRecaptcha(token: string): Promise<boolean> {
  if (!token) return false;

  const res = await fetch(
    `https://www.google.com/recaptcha/api/siteverify?secret=${process.env.RECAPTCHA_SECRET_KEY}&response=${token}`,
    { method: "POST" }
  );

  const data = await res.json();
  return data.success === true;
}
Enter fullscreen mode Exit fullscreen mode
const isHuman = await verifyRecaptcha(recaptchaToken);

if (!isHuman) {
  return Response.json(
    { success: false, error: "reCAPTCHA verification failed." },
    { status: 400 }
  );
}
Enter fullscreen mode Exit fullscreen mode

Putting It All Together (Next.js API Route)

export async function POST(req: NextRequest) {
  // 1. Get IP
  const ip =
    req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";

  // 2. Rate limit check
  if (isRateLimited(ip)) {
    return Response.json(
      { success: false, error: "Too many requests." },
      { status: 429 }
    );
  }

  const { name, email, message, honeypot, recaptchaToken } = await req.json();

  // 3. Honeypot check
  if (isBot(honeypot)) {
    return Response.json({ success: true }); // Silent drop
  }

  // 4. reCAPTCHA check
  const isHuman = await verifyRecaptcha(recaptchaToken);
  if (!isHuman) {
    return Response.json(
      { success: false, error: "reCAPTCHA verification failed." },
      { status: 400 }
    );
  }

  // ✅ Safe to send email
}
Enter fullscreen mode Exit fullscreen mode

A request only reaches your email logic if it passes all three checks.


The Result

Before 400 spam emails in ~3 minutes
After 0 spam emails

Not one since.


Going Further in Production

This setup is lightweight and perfect for a portfolio. If you need more:

  • Redis / Upstash — distributed rate limiting that survives server restarts
  • Cloudflare Turnstile — a privacy-friendlier CAPTCHA alternative
  • Arcjet — modern security layer with bot detection built in
  • Vercel KV — great if you're already in the Vercel ecosystem

The Takeaway

Security doesn't need to be complicated.

Layer Purpose
Honeypot Catches dumb bots instantly
Rate Limiting Stops flooding & repeat attempts
reCAPTCHA Verifies a real human is submitting

Stack them together and your contact form becomes very hard to abuse — with zero impact on real users.


Have you dealt with bot spam on your own projects? What approach did you take? Drop it in the comments.

Top comments (0)