Problem
Comment forms on blogs are prime targets for spam bots. The typical solution is CAPTCHA, but CAPTCHAs annoy legitimate users and rely on third-party services. How do you block automated spam without degrading the user experience?
This was a rebuild of an original ASP.NET MVC implementation (documented at jnye.co/posts/2029/how-to-stop-spam-bots-from-submitting-your-form-in-mvc) into a Next.js app with React Hook Form and Server Actions.
Solution
A multi-layered approach using three complementary techniques:
-
Honeypot field - A hidden form field that humans never see but bots fill automatically. Server returns fake success to not tip off the bot.
-
Timestamp validation - Form generates an obfuscated timestamp on mount. Server rejects submissions that are too fast (< 3 seconds, likely automated) or too old (> 20 minutes, stale/replayed form).
-
Salt generation - Random string generated per form load, adds uniqueness to prevent simple replay attacks.
Key architectural decision: Client generates timestamp and salt, server validates everything including computing its own hash with IP. This keeps the validation algorithm hidden server-side.
Field names are obfuscated with random-looking strings (e.g., XYZABC instead of honeypot) so bots can't guess their purpose from the name.
Tech Stack
- Next.js (App Router)
- React Hook Form with Zod validation
- Server Actions
- TypeScript
Code Snippet
Timestamp encoding (obfuscated format):
// Scramble date components to confuse bots parsing the value
export function encodeTimestamp(date: Date = new Date()): string {
const pad = (n: number) => n.toString().padStart(2, '0');
// Combine date parts in a non-standard order
return `${pad(date.getMonth() + 1)}${pad(date.getDate())}...${date.getFullYear()}`;
}
Server-side validation:
// Honeypot check - return fake success to fool bots
if (formData.honeypotField) {
return {message: "Comment added successfully", success: true, data: null}
}
// Timestamp window validation
const timestampValidation = validateTimestamp(formData.timestampField || "");
if (!timestampValidation.valid) {
return {message: timestampValidation.reason || "Invalid submission", success: false}
}
Client-side generation on form mount:
useEffect(() => {
form.setValue("timestampField", encodeTimestamp());
form.setValue("saltField", generateSalt());
}, [form]);
Gotchas
-
React Hook Form state vs DOM - Setting input.value directly in dev tools doesn't update React Hook Form's internal state. This is actually a security benefit - bots trying to manipulate the DOM won't affect the submitted data.
-
Most bots don't execute JavaScript - They parse HTML and POST directly. This means:
- Honeypot works because they fill ALL fields
- Timestamp works because they can't generate one without JS
-
Salt visibility is OK - The salt being in the form isn't a security risk because the server-side validation uses the IP address (which bots can't predict) in its hash computation.
-
Fake success for honeypot - Critical to return
success: truewhen honeypot is triggered, otherwise bots learn to avoid that field. -
Time window tuning - 3 seconds minimum (humans need time to type), 20 minutes maximum (forms shouldn't be left open forever). These are configurable.
Raw Notes
- Original MVC implementation used AntiForgeryToken for CSRF - Next.js Server Actions have built-in protection
- This stops ~95% of spam without annoying users with CAPTCHAs
- For additional protection could add: rate limiting per IP, Akismet integration, optional CAPTCHA for suspicious submissions
- Obfuscated field names make automated form-filling harder as bots can't guess field purposes from names