DEV Community

Alex Chen
Alex Chen

Posted on

Adding CAPTCHA Protection to Your Django or Flask App (And How Attackers Bypass It)

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")
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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,
    })
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
    )
Enter fullscreen mode Exit fullscreen mode

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"])
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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"])
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
@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"])
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Always verify server-side — client-side CAPTCHA is just UI
  2. Check the success field — don't just send the request
  3. CAPTCHAs are speed bumps, not walls — layer with rate limiting, honeypots, email verification
  4. Use test keys in CI — don't skip CAPTCHA checks in tests
  5. 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)