Skip to main content
Beta: Front-End Checklist is currently in beta. Some issues are still being fixed. Thanks for your patience.
SecurityMedium

Protect public forms with CAPTCHA

Public forms that accept user input without authentication must include bot protection to prevent spam, credential stuffing, and automated abuse.

Utilities
Quick take
Typical fix time 30 min
  • Public forms (contact, registration, login, password reset, comment) without CAPTCHA are targets for automated abuse
  • Prefer invisible/automated solutions (Cloudflare Turnstile, Google reCAPTCHA v3, hCaptcha) over interactive challenges that harm UX
  • Always validate CAPTCHA tokens server-side — client-side validation is bypassable
  • Rate limiting is complementary to CAPTCHA but not a substitute — bots can solve rate limits with distributed attacks
  • Honeypot fields (hidden inputs that users never fill but bots do) are a lightweight CAPTCHA alternative for low-risk forms
Why it matters: An unprotected registration form can create thousands of spam accounts per minute; an unprotected login form enables credential stuffing attacks that test millions of username/password combinations from data breaches.

Rule Details

Automated bots constantly probe public web forms for weaknesses. Without bot protection, a single form can generate thousands of spam submissions, enable account takeover through credential stuffing (opens in new tab), or exhaust your email-sending limits.

Code Examples

Privacy-friendly, requires no user interaction in most cases, and does not use Google's tracking infrastructure.

<!-- 1. Include the script -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
 
<!-- 2. Add the widget to your form -->
<form method="POST" action="/submit">
  <input type="text" name="email" placeholder="Email">
 
  <!-- Turnstile widget — renders automatically -->
  <div class="cf-turnstile"
       data-sitekey="YOUR_SITE_KEY"
       data-theme="auto">
  </div>
 
  <button type="submit">Submit</button>
</form>
// 3. Validate server-side (Next.js API route)
export async function POST(request: Request) {
  const formData = await request.formData()
  const token = formData.get('cf-turnstile-response') as string
  const ip = request.headers.get('CF-Connecting-IP') || ''
 
  // Verify with Cloudflare
  const verifyResponse = await fetch(
    'https://challenges.cloudflare.com/turnstile/v0/siteverify',
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        secret: process.env.TURNSTILE_SECRET_KEY,
        response: token,
        remoteip: ip,
      }),
    }
  )
 
  const verification = await verifyResponse.json()
 
  if (!verification.success) {
    return Response.json({ error: 'CAPTCHA verification failed' }, { status: 400 })
  }
 
  // Process the form submission
  // ...
}

Google reCAPTCHA v3 (opens in new tab) (Invisible)

Scores user interactions from 0.0 (bot) to 1.0 (human) without requiring user interaction.

<script src="https://www.google.com/recaptcha/api.js?render=YOUR_SITE_KEY"></script>
 
<form id="contact-form" method="POST" action="/submit">
  <input type="text" name="email" placeholder="Email">
  <button type="submit" id="submit-btn">Submit</button>
</form>
 
<script>
document.getElementById('contact-form').addEventListener('submit', async (e) => {
  e.preventDefault()
 
  const token = await grecaptcha.execute('YOUR_SITE_KEY', { action: 'contact' })
 
  // Add token to form data
  const formData = new FormData(e.target)
  formData.append('g-recaptcha-response', token)
 
  await fetch('/submit', { method: 'POST', body: formData })
})
</script>
// Server-side validation
const verifyResponse = await fetch(
  `https://www.google.com/recaptcha/api/siteverify`,
  {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: `secret=${process.env.RECAPTCHA_SECRET_KEY}&response=${token}`,
  }
)
const { success, score } = await verifyResponse.json()
 
// Reject low-score requests (< 0.5 is typically a bot)
if (!success || score < 0.5) {
  return Response.json({ error: 'Suspicious request' }, { status: 400 })
}

Honeypot Fields (Lightweight Alternative)

A honeypot adds a hidden field that real users never see or fill. Bots that blindly fill all fields will reveal themselves.

<form method="POST" action="/contact">
  <input type="text" name="email" placeholder="Email">
  <textarea name="message" placeholder="Message"></textarea>
 
  <!-- Honeypot: hidden from users via CSS, not via display:none or visibility:hidden -->
  <div aria-hidden="true" style="position:absolute; left:-9999px; width:1px; height:1px; overflow:hidden;">
    <label for="website">Website (leave blank)</label>
    <input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
  </div>
 
  <button type="submit">Send</button>
</form>
// Server: reject if honeypot is filled
const website = formData.get('website')
if (website) {
  // Bot detected — silently succeed to avoid revealing the trap
  return Response.json({ success: true })
}
Always Validate Server-Side

CAPTCHA tokens verified only in the browser (client-side) provide no protection. Bots can bypass client-side checks by directly calling your API, which is why OWASP's automated threats guidance (opens in new tab) treats server-side validation as mandatory rather than optional hardening.

Why It Matters

An unprotected registration form can create thousands of spam accounts per minute; an unprotected login form enables credential stuffing attacks that test millions of username/password combinations from data breaches.

Forms That Need Protection

Form TypePrimary RiskCAPTCHA Priority
LoginCredential stuffing, brute forceHigh
RegistrationFake account creation, spamHigh
Password resetAccount takeover via email enumerationHigh
Contact / feedbackSpam, phishingMedium
Newsletter subscriptionList bombingMedium
Comment / reviewSpam contentMedium
SearchScraping, abuseLow

Rate Limiting as Defense-in-Depth

Combine CAPTCHA with rate limiting to block even bots that solve CAPTCHAs:

// Using Upstash Rate Limit (Redis-backed)
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
 
const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, '1 m'), // 5 requests per minute per IP
})
 
export async function POST(request: Request) {
  const ip = request.headers.get('x-forwarded-for') ?? '127.0.0.1'
  const { success } = await ratelimit.limit(ip)
 
  if (!success) {
    return Response.json(
      { error: 'Too many requests' },
      { status: 429, headers: { 'Retry-After': '60' } }
    )
  }
 
  // Continue with CAPTCHA validation and form processing
}

Exceptions

  • A weaker form control is only acceptable when the business requirement and compensating controls are documented explicitly.
  • If the flow is already transport-insecure, inaccessible, or externally embedded in a way that changes the threat model, fix that stronger issue first.
  • False positives are common on demo, sandbox, or intentionally constrained flows, but they should still be bounded and clearly labeled.

Verification

Automated Checks

  • Test the affected flow in a production-like environment, not just local development.
  • Document any intentional exceptions explicitly.

Manual Checks

  • Inspect the final HTTP response or browser behavior to confirm the control is actually enforced.
  • Verify third-party integrations or embeds still work after the restriction is applied.

Use with AI

Copy these prompts to use with your AI assistant, or install the MCP server to use directly from Claude, Cursor, or Windsurf.

Check

Verify implementation

Identify all public-facing forms (contact, registration, login, password reset, newsletter, comment). Check whether each has CAPTCHA, honeypot fields, or server-side rate limiting. Verify any CAPTCHA tokens are validated server-side.

Fix

Auto-fix issues

Integrate a CAPTCHA service (Cloudflare Turnstile, hCaptcha, or Google reCAPTCHA v3) on all public forms. Validate the CAPTCHA response token on your server before processing the form submission. Add rate limiting as a defense-in-depth measure.

Explain

Learn more

Explain what credential stuffing and spam bot attacks are, how CAPTCHA protects public forms, the trade-offs between different CAPTCHA approaches (v2 checkbox, v3 invisible, Turnstile), and why server-side validation is required.

Review

Code review

Review server config, headers, forms, and integration points related to Protect public forms with CAPTCHA. Flag exact responses, cookies, or browser behaviors that violate the rule, and verify them against the effective production-like response.

Sources

References used to support the guidance in this rule.

Further Reading

Tools and supplementary material for exploring the topic in more depth.

Mozilla Observatory
observatory.mozilla.orgTool

Rules that often go hand-in-hand with this one.

Submit forms over HTTPS

All HTML form actions must point to HTTPS URLs to ensure form data is encrypted in transit and cannot be intercepted by network attackers.

Security
Redirect HTTP to HTTPS

All HTTP requests must be permanently redirected (301) to HTTPS to prevent users from accessing your site over an insecure connection.

Security
Secure password input fields

Password fields implement security best practices including proper autocomplete, show/hide toggle, and strength indicators.

Security
Allow pasting into form inputs

Users should be able to paste content into form fields to improve usability and reduce errors.

Accessibility

Was this rule helpful?

Your feedback helps improve rule quality. This stays internal for now.

Loading feedback...
0 / 385