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"
/>
Backend check:
export function isBot(honeypot: unknown): boolean {
return typeof honeypot === "string" && honeypot.trim().length > 0;
}
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 });
}
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;
}
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 }
);
}
⚠️ 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 }),
});
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;
}
const isHuman = await verifyRecaptcha(recaptchaToken);
if (!isHuman) {
return Response.json(
{ success: false, error: "reCAPTCHA verification failed." },
{ status: 400 }
);
}
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
}
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)