JN
2 min read

Custom CAPTCHA alternative for Nextjs

#security#nextjs#reactjs#typescript

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

Leave a comment