2 min read

Custom CAPTCHA alternative for Nextjs

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:

  1. Honeypot field - A hidden form field that humans never see but bots fill automatically. Server returns fake success to not tip off the bot.

  2. 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).

  3. 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

  1. 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.

  2. 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
  3. 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.

  4. Fake success for honeypot - Critical to return success: true when honeypot is triggered, otherwise bots learn to avoid that field.

  5. 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