You've built a web app. Users are signing up, submitting forms, leaving comments. Then the bots arrive — fake signups, spam comments, credential stuffing.
You need CAPTCHA protection. But adding it wrong is worse than not adding it at all — it gives you a false sense of security while bots walk right through.
This guide shows you how to properly integrate CAPTCHAs in Django and Flask, the common mistakes that make them bypassable, and how to verify you've done it right.
Choosing a CAPTCHA Provider
| Provider | Difficulty | Privacy | Free Tier | Best For |
|---|---|---|---|---|
| reCAPTCHA v2 | Medium | Low (Google tracking) | 10K/month | Legacy apps |
| reCAPTCHA v3 | Invisible | Low | 10K/month | UX-sensitive apps |
| hCaptcha | Medium | High | Unlimited | Privacy-conscious |
| Turnstile | Invisible | Medium | Unlimited | Cloudflare users |
For this tutorial, we'll use hCaptcha — it's free, privacy-friendly, and works everywhere.
Django Integration
Step 1: Get Your Keys
Sign up at hcaptcha.com and get your:
- Site Key (public, goes in HTML)
- Secret Key (private, stays on server)
Step 2: Create a CAPTCHA Form Mixin
# captcha/mixins.py
import httpx
from django import forms
from django.conf import settings
class CaptchaMixin:
"""Add hCaptcha validation to any Django form."""
def clean(self):
cleaned_data = super().clean()
captcha_response = self.data.get(
"h-captcha-response", ""
)
if not captcha_response:
raise forms.ValidationError(
"Please complete the CAPTCHA."
)
# Verify server-side — NEVER skip this
result = httpx.post(
"https://hcaptcha.com/siteverify",
data={
"secret": settings.HCAPTCHA_SECRET_KEY,
"response": captcha_response,
"remoteip": self.get_client_ip(),
}
).json()
if not result.get("success"):
raise forms.ValidationError(
"CAPTCHA verification failed. "
"Please try again."
)
return cleaned_data
def get_client_ip(self):
"""Extract client IP from the request."""
x_forwarded_for = self.request.META.get(
"HTTP_X_FORWARDED_FOR"
)
if x_forwarded_for:
return x_forwarded_for.split(",")[0].strip()
return self.request.META.get("REMOTE_ADDR")
Step 3: Apply to Your Forms
# accounts/forms.py
from django import forms
from captcha.mixins import CaptchaMixin
class SignupForm(CaptchaMixin, forms.Form):
username = forms.CharField(max_length=150)
email = forms.EmailField()
password = forms.CharField(
widget=forms.PasswordInput
)
def __init__(self, *args, request=None, **kwargs):
self.request = request
super().__init__(*args, **kwargs)
class ContactForm(CaptchaMixin, forms.Form):
name = forms.CharField(max_length=100)
message = forms.CharField(
widget=forms.Textarea
)
def __init__(self, *args, request=None, **kwargs):
self.request = request
super().__init__(*args, **kwargs)
Step 4: Template
<!-- templates/signup.html -->
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<!-- hCaptcha widget -->
<div class="h-captcha"
data-sitekey="{{ HCAPTCHA_SITE_KEY }}">
</div>
<button type="submit">Sign Up</button>
</form>
<script src="https://js.hcaptcha.com/1/api.js"
async defer></script>
Step 5: View
# accounts/views.py
from django.shortcuts import render, redirect
from django.conf import settings
from .forms import SignupForm
def signup(request):
if request.method == "POST":
form = SignupForm(
request.POST, request=request
)
if form.is_valid():
# CAPTCHA passed — create user
create_user(form.cleaned_data)
return redirect("dashboard")
else:
form = SignupForm()
return render(request, "signup.html", {
"form": form,
"HCAPTCHA_SITE_KEY": settings.HCAPTCHA_SITE_KEY,
})
Flask Integration
Step 1: CAPTCHA Decorator
# captcha.py
import httpx
import functools
from flask import request, abort, current_app
def require_captcha(f):
"""Decorator to require CAPTCHA verification."""
@functools.wraps(f) def decorated(*args, **kwargs):
if request.method != "POST":
return f(*args, **kwargs)
token = request.form.get(
"h-captcha-response", ""
)
if not token:
abort(400, "Missing CAPTCHA response")
result = httpx.post(
"https://hcaptcha.com/siteverify",
data={
"secret": current_app.config[
"HCAPTCHA_SECRET"
],
"response": token,
"remoteip": request.remote_addr,
}
).json()
if not result.get("success"):
abort(403, "CAPTCHA verification failed")
return f(*args, **kwargs)
return decorated
Step 2: Apply to Routes
# app.py
from flask import Flask, render_template, request
from captcha import require_captcha
app = Flask(__name__)
app.config["HCAPTCHA_SECRET"] = "your-secret-key"
app.config["HCAPTCHA_SITEKEY"] = "your-site-key"
@app.route("/signup", methods=["GET", "POST"])
@require_captchadef signup():
if request.method == "POST":
# CAPTCHA already verified by decorator
username = request.form["username"]
email = request.form["email"]
create_user(username, email)
return redirect("/dashboard")
return render_template(
"signup.html",
sitekey=app.config["HCAPTCHA_SITEKEY"]
)
@app.route("/contact", methods=["GET", "POST"])
@require_captchadef contact():
if request.method == "POST":
send_message(request.form["message"])
return redirect("/thanks")
return render_template(
"contact.html",
sitekey=app.config["HCAPTCHA_SITEKEY"]
)
Common Mistakes (And How Attackers Exploit Them)
Mistake 1: Client-Side Only Validation
# BAD — no server-side check
@app.route("/signup", methods=["POST"])
def signup():
# Just trusts the form submission...
create_user(request.form["username"])
Attack: Simply POST to /signup\ without the CAPTCHA field. The server never checks.
Fix: Always verify the token server-side with the siteverify\ endpoint.
Mistake 2: Not Checking the Response
# BAD — sends verification but ignores result
result = httpx.post(
"https://hcaptcha.com/siteverify",
data={"secret": secret, "response": token}
).json()
# Forgot to check result["success"]!
create_user(data)
Attack: Send any random string as the CAPTCHA token. The server verifies it, gets success: false\, and ignores it.
Mistake 3: Reusable Tokens
# BAD — doesn't track used tokens
@app.route("/vote", methods=["POST"])
@require_captchadef vote():
add_vote(request.form["option"])
Attack: Solve CAPTCHA once, replay the same token on multiple votes. hCaptcha tokens are single-use by default at the provider level, but you should also track them:
# GOOD — track used tokens
used_tokens = set()
def verify_captcha(token):
if token in used_tokens:
return False
result = httpx.post(
"https://hcaptcha.com/siteverify",
data={"secret": secret, "response": token}
).json()
if result["success"]:
used_tokens.add(token)
return True
return False
Mistake 4: CAPTCHA on GET, Not POST
# BAD — CAPTCHA only shown on the form page
@app.route("/api/submit", methods=["POST"])
def api_submit():
# No CAPTCHA check — assumes user went through
# the form page first
process(request.json)
Attack: Call the API endpoint directly, skipping the form page entirely.
How Scrapers Bypass Your CAPTCHA
Understanding the attacker's perspective helps you build better defenses. Here's how automated tools handle CAPTCHAs:
# What a scraper does to bypass your form
import httpx
# 1. Load the page, find the sitekey
resp = httpx.get("https://yourapp.com/signup")
sitekey = extract_sitekey(resp.text)
# 2. Solve the CAPTCHA via API
token = solve_captcha(
type="hcaptcha",
sitekey=sitekey,
url="https://yourapp.com/signup"
)
# 3. Submit the form with the solved token
httpx.post("https://yourapp.com/signup", data={
"username": "bot_user",
"email": "bot@example.com", "h-captcha-response": token
})
Tools like passxapi-python make this straightforward. The solve produces a valid token that passes server-side verification.
Defense in Depth
CAPTCHAs alone aren't enough. Layer your defenses:
# middleware.py
from flask import request, abort
import time
class AntiAbuseMiddleware:
def __init__(self, app):
self.app = app
self.rate_limiter = RateLimiter()
self.fingerprinter = BrowserFingerprint()
def __call__(self, environ, start_response):
with self.app.request_context(environ):
ip = request.remote_addr
# Layer 1: Rate limiting
if self.rate_limiter.is_exceeded(ip):
abort(429)
# Layer 2: Request timing analysis
if self.is_too_fast(request):
abort(429)
# Layer 3: CAPTCHA (handled by decorator)
# Layer 4: Email verification (post-signup)
# Layer 5: Behavioral analysis
return self.app(environ, start_response)
def is_too_fast(self, req):
"""Flag requests submitted faster than
a human could type."""
form_load_time = req.form.get("_form_loaded")
if form_load_time:
elapsed = time.time() - float(form_load_time)
if elapsed < 3: # < 3 seconds = suspicious
return True
return False
Add Hidden Honeypot Fields
<!-- Bots fill hidden fields, humans don't -->
<div style="display:none">
<input type="text"
name="website"
tabindex="-1"
autocomplete="off">
</div>
@app.route("/signup", methods=["POST"])
@require_captchadef signup():
# Honeypot check
if request.form.get("website"):
# Bot filled the hidden field
return redirect("/thanks") # Fake success
create_user(request.form["username"])
Testing Your CAPTCHA Integration
Use test keys to verify without solving real CAPTCHAs:
# settings.py (test configuration)
import os
if os.getenv("TESTING"):
# hCaptcha test keys — always pass
HCAPTCHA_SITE_KEY = "10000000-ffff-ffff-ffff-000000000001"
HCAPTCHA_SECRET_KEY = "0x0000000000000000000000000000000000000000"
else:
HCAPTCHA_SITE_KEY = os.getenv("HCAPTCHA_SITE_KEY")
HCAPTCHA_SECRET_KEY = os.getenv("HCAPTCHA_SECRET_KEY")
# tests/test_signup.py
import pytest
@pytest.fixturedef app():
os.environ["TESTING"] = "1"
from app import create_app
return create_app()
def test_signup_with_captcha(client):
resp = client.post("/signup", data={
"username": "testuser",
"email": "test@example.com", "h-captcha-response": "10000000-aaaa-bbbb-cccc-000000000001"
})
assert resp.status_code == 302 # Redirect to dashboard
def test_signup_without_captcha(client):
resp = client.post("/signup", data={
"username": "testuser",
"email": "test@example.com", # No CAPTCHA token
})
assert resp.status_code == 400
Key Takeaways
- Always verify server-side — client-side CAPTCHA is just UI
-
Check the
successfield — don't just send the request - CAPTCHAs are speed bumps, not walls — layer with rate limiting, honeypots, email verification
- Use test keys in CI — don't skip CAPTCHA checks in tests
- Understand the attacker — if you know how CAPTCHAs get solved, you can build better defenses
What CAPTCHA provider are you using in production? How do you layer your defenses? Let's discuss in the comments.
Top comments (0)