django-captcha-kit
An interchangeable CAPTCHA service for Django. Protect your forms with Cloudflare Turnstile, Google reCAPTCHA or hCaptcha, and switch provider by editing one line of configuration without touching a single form.
No dependencies beyond Django itself: verification uses the standard library only.
Try the live demo — the same contact form protected by all five providers, side by side, with the code for each integration path.
Table of contents
- Why this package
- Requirements
- Installation
- Quick start
- Usage
- Configuration reference
- Built-in providers
- Security notes
- Writing your own provider
- Testing your project
- Development
- License
Why this package
Every CAPTCHA service works the same way: render a widget, read a token from the POST data, verify it. Only three things actually differ between them, so this package reduces a provider to a three-method contract, plus one hook for the rare widget that submits more than one input:
| Method | Responsibility |
|---|---|
field() |
Name of the POST field the widget submits |
render() |
HTML snippet to inject into the form |
verify(value, ip=None) |
Server-side verification of the submitted token |
value_from_datadict(data) |
Optional. Reads the field named by field() unless the widget submits several inputs |
Your application code never references a concrete provider. It talks to a form field; the field asks the registry; the registry reads your settings. Swapping Turnstile for hCaptcha is a settings change, and running without any CAPTCHA in development is a settings change too.
Requirements
- Python 3.12 or newer
- Django 5.2 or newer
Installation
With pip:
pip install django-captcha-kit
With uv:
uv add django-captcha-kit
With PDM:
pdm add django-captcha-kit
Add the app to your settings:
INSTALLED_APPS = [
# ...
"captcha_kit",
]
The app is only needed for its templates and its system checks; there are no models and no migrations.
Quick start
# settings.py
CAPTCHA_KIT = {
"DEFAULT": env("CAPTCHA_DRIVER", default="none"),
"PROVIDERS": {
"turnstile": {
"SITE_KEY": env("TURNSTILE_SITE_KEY"),
"SECRET_KEY": env("TURNSTILE_SECRET_KEY"),
"TIMEOUT": 5,
},
},
}
# forms.py
from django import forms
from captcha_kit.forms import CaptchaFormMixin
class ContactForm(CaptchaFormMixin, forms.Form):
email = forms.EmailField()
message = forms.CharField(widget=forms.Textarea)
# views.py
def contact(request):
form = ContactForm(request.POST or None, request=request)
if request.method == "POST" and form.is_valid():
...
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Send</button>
</form>
Passing request=request is optional but recommended: it forwards the client IP to the
verification endpoint as remoteip, which most providers use for risk scoring.
The none driver, used by default, renders a hidden field and always validates. Keep it in
development and in tests, and point DEFAULT at a real provider in production.
Usage
With the form mixin
CaptchaFormMixin adds a captcha field and wires the client IP for you. This is the
recommended integration.
class ContactForm(CaptchaFormMixin, forms.Form):
email = forms.EmailField()
With the field alone
Use CaptchaField directly when you want to control the field name, its position, or use a
provider other than the default one.
from captcha_kit.fields import CaptchaField
class ContactForm(forms.Form):
email = forms.EmailField()
captcha = CaptchaField()
# captcha = CaptchaField("hcaptcha") # a specific alias
# captcha = CaptchaField(attrs={"data-theme": "dark"}) # widget attributes
Without the mixin, forward the client IP yourself if you want it verified:
from captcha_kit.forms import get_client_ip
form = ContactForm(request.POST)
form.fields["captcha"].set_ip(get_client_ip(request))
In a template, without a form class
{% load captcha_kit %}
<form method="post">
{% csrf_token %}
{% captcha %}
{% comment %} {% captcha "hcaptcha" %} to force a specific alias {% endcomment %}
<button type="submit">Send</button>
</form>
The tag only renders the widget. Server-side verification still has to happen, either through
a form using CaptchaField or by calling the provider yourself:
from captcha_kit.registry import get_captcha_provider
provider = get_captcha_provider()
token = request.POST.get(provider.field())
if not provider.verify(token, ip=get_client_ip(request)):
...
Configuration reference
Everything lives under the single CAPTCHA_KIT setting.
Top-level keys
| Key | Type | Default | Description |
|---|---|---|---|
DEFAULT |
str |
"none" |
Alias used when no alias is given explicitly |
PROVIDERS |
dict |
{} |
Per-alias configuration, see below |
TRUSTED_PROXY_COUNT |
int |
0 |
Number of reverse proxies you control in front of the app. 0 ignores X-Forwarded-For entirely |
Provider keys
Each entry of PROVIDERS is a dictionary. BACKEND selects the implementation; every other
key is lower-cased and passed to the provider constructor, so SITE_KEY becomes site_key.
| Key | Type | Default | Description |
|---|---|---|---|
BACKEND |
str |
built-in for known aliases | Import path of a BaseCaptchaProvider subclass |
The remaining keys are provider-specific. Turnstile, reCAPTCHA and hCaptcha accept:
| Key | Type | Default | Description |
|---|---|---|---|
SITE_KEY |
str |
required | Public key rendered in the widget |
SECRET_KEY |
str |
required | Private key sent to the verification endpoint |
TIMEOUT |
int |
5 |
Socket timeout of the verification call, in seconds |
VERIFY_URL |
str |
provider default | Overrides the endpoint, for a proxy or a self-hosted deployment |
VERIFY_HOSTNAME |
bool |
True |
Rejects a token that was solved on an unexpected host |
HOSTNAMES |
list[str] |
settings.ALLOWED_HOSTS |
Hosts accepted when VERIFY_HOSTNAME is on |
The math provider has its own keys, and none takes none.
BACKEND may be omitted for the five built-in aliases. A full example:
CAPTCHA_KIT = {
"DEFAULT": "turnstile",
"TRUSTED_PROXY_COUNT": 1,
"PROVIDERS": {
"turnstile": {
"SITE_KEY": env("TURNSTILE_SITE_KEY"),
"SECRET_KEY": env("TURNSTILE_SECRET_KEY"),
"TIMEOUT": 5,
"HOSTNAMES": ["example.com", ".example.com"],
},
"hcaptcha": {
"SITE_KEY": env("HCAPTCHA_SITE_KEY"),
"SECRET_KEY": env("HCAPTCHA_SECRET_KEY"),
},
},
}
Built-in providers
| Alias | Service | POST field | Documentation |
|---|---|---|---|
none |
No verification, for development and tests | captcha |
- |
math |
Local Math Captcha, no third party | captcha-answer |
see below |
turnstile |
Cloudflare Turnstile | cf-turnstile-response |
developers.cloudflare.com/turnstile |
recaptcha |
Google reCAPTCHA v2 (checkbox) | g-recaptcha-response |
developers.google.com/recaptcha |
hcaptcha |
hCaptcha | h-captcha-response |
docs.hcaptcha.com |
The POST field name is imposed by each service and has nothing to do with the name of the field in your Django form. It is resolved transparently by the widget, so you can name the form field whatever you like.
Local Math Captcha
The math provider asks the visitor to solve a small sum. It contacts nothing, loads no
third-party script, sets no cookie and needs no account, which makes it a fit for intranets,
air-gapped deployments, or any site that would rather not send visitor data to a CAPTCHA
vendor.
CAPTCHA_KIT = {
"DEFAULT": "math",
"PROVIDERS": {
"math": {
"OPERATORS": ["+", "-"],
"MAX_TERM": 10,
"MAX_AGE": 600,
},
},
}
| Key | Type | Default | Description |
|---|---|---|---|
OPERATORS |
list[str] |
["+", "-"] |
Symbols the challenge may use, among +, - and * |
MAX_TERM |
int |
10 |
Largest term of the challenge |
MAX_AGE |
int |
600 |
Lifetime of a challenge, in seconds |
SINGLE_USE |
bool |
True |
Consume a challenge on its first verification |
CACHE_ALIAS |
str |
"default" |
Cache backing the single-use guard |
TEMPLATE_NAME |
str |
"captcha_kit/math.html" |
Template rendering the challenge |
The widget submits two inputs: the answer, and a hidden challenge token. The token carries a
keyed hash of the expected answer, derived from settings.SECRET_KEY and a per-render nonce,
so the server keeps no state between rendering and verification, the answer is never readable
by the client, and no two tokens are alike. Subtraction is ordered so the answer is never
negative.
A challenge is consumed on its first verification, correct or not: one answer, one attempt.
That guard lives in the cache, so it is only as shared as the cache is. With the default
local-memory cache each worker enforces it on its own; point CACHE_ALIAS at Redis or
Memcached for a guarantee across a whole deployment, or set SINGLE_USE to False if you
accept replays within MAX_AGE.
Override the template to restyle the challenge. It receives question, token,
answer_field and challenge_field:
"PROVIDERS": {"math": {"TEMPLATE_NAME": "myapp/math_captcha.html"}}
Be clear-eyed about what this buys you. A math challenge stops naive form-filling bots; it does not stop a targeted attacker, who can parse the question and solve it. The answer space is also small, so pair it with rate limiting on the view. When you need real bot resistance, use Turnstile, reCAPTCHA or hCaptcha.
Security notes
Verification fails closed
If the verification endpoint times out, returns malformed JSON, drops the connection or fails
TLS negotiation, the token is rejected and a warning is logged on the captcha_kit logger. An
outage of the CAPTCHA service never turns into an open door.
LOGGING = {
"version": 1,
"loggers": {
"captcha_kit": {"handlers": ["console"], "level": "WARNING"},
},
}
Hostname verification
Site keys are public by construction: anyone can embed your widget on their own page, collect
valid tokens, and replay them against your forms. The verification response carries the
hostname the token was solved on, and it is checked against settings.ALLOWED_HOSTS by
default.
Set HOSTNAMES on the provider when the CAPTCHA is served from a host that differs from
ALLOWED_HOSTS. Entries follow the ALLOWED_HOSTS syntax, so ".example.com" matches any
subdomain and "*" matches everything. Set VERIFY_HOSTNAME to False to disable the check
entirely.
Client IP and reverse proxies
X-Forwarded-For is attacker-controlled: any client can send it, and each proxy appends to it
rather than replacing it. Only the entries appended by proxies you own can be trusted, so the
header is ignored unless you declare how many of them sit in front of the application:
CAPTCHA_KIT = {
"TRUSTED_PROXY_COUNT": 1, # one reverse proxy, for instance nginx or a load balancer
# ...
}
With n trusted proxies, the client address is read as the n-th entry counting from the
right, so anything a client prepends to the header is discarded. Leave the setting at 0 when
the application is exposed directly; REMOTE_ADDR is then used as-is.
System checks
Two checks warn when the effective configuration protects nothing:
| Identifier | Raised by | Condition |
|---|---|---|
captcha_kit.W001 |
manage.py check |
The app is installed but CAPTCHA_KIT is undefined, so none is silently in use |
captcha_kit.W002 |
manage.py check --deploy |
DEFAULT resolves to none, so no CAPTCHA is enforced |
W002 is a deployment check and stays quiet during normal development. Silence either one
through SILENCED_SYSTEM_CHECKS if it does not apply to your setup.
Writing your own provider
Implement the three-method contract:
from captcha_kit.contracts import BaseCaptchaProvider
class MyCaptcha(BaseCaptchaProvider):
def field(self) -> str:
return "my-captcha-response"
def render(self) -> str:
return '<div class="my-captcha"></div>'
def verify(self, value: str, ip: str | None = None) -> bool:
return check_the_token(value, ip)
Register it under any alias:
CAPTCHA_KIT = {
"DEFAULT": "custom",
"PROVIDERS": {
"custom": {"BACKEND": "myapp.captcha.MyCaptcha", "OPTION_X": "..."},
},
}
Configuration keys other than BACKEND are lower-cased and passed as keyword arguments, so
OPTION_X arrives as option_x="...".
If your service follows the standard siteverify protocol, that is
POST secret/response/remoteip returning {"success": bool}, subclass
SiteVerifyProvider instead and get the HTTP handling, the fail-closed behaviour and the
hostname verification for free:
from captcha_kit.providers.base import SiteVerifyProvider
class MyCaptcha(SiteVerifyProvider):
verify_url = "https://example.com/siteverify"
field_name = "my-captcha-response"
template_name = "myapp/my_captcha.html"
The template receives the site_key variable.
Widgets that submit several inputs
verify() receives a single string, read by default from the field named by field(). When
your widget renders more than one input, override value_from_datadict to combine them, and
return None when nothing was filled in so that required still applies. This is how the
math provider carries both the typed answer and its hidden challenge token:
def value_from_datadict(self, data) -> str | None:
answer = (data.get("my-answer") or "").strip()
if not answer:
return None
return f"{data.get('my-challenge') or ''}:{answer}"
Testing your project
Keep "DEFAULT": "none" in your test settings and forms validate without any network call:
CAPTCHA_KIT = {"DEFAULT": "none"}
To exercise a real provider, mock the verification call rather than the provider itself:
from unittest import mock
with mock.patch("captcha_kit.providers.base.SiteVerifyProvider.verify", return_value=True):
assert form.is_valid()
override_settings(CAPTCHA_KIT=...) is supported out of the box: the provider cache listens
to Django's setting_changed signal and is rebuilt automatically.
Development
The project is managed with PDM:
git clone https://github.com/Macktireh/django-captcha-kit.git
cd django-captcha-kit
pdm install
pdm run pytest
pdm run ruff check
pdm run ruff format
With pip instead, development dependencies live in the PEP 735 dev group:
pip install -e .
pip install --group dev # pip 25.1 or newer
pytest
Running the demo locally
The site behind the live demo lives in
example/django_app_demo. It has no database and no
migrations, and its .env.example ships the public test keys of each service, so it runs
as soon as it is installed:
cd example/django_app_demo
cp .env.example .env
pdm install
pdm run python manage.py runserver
License
MIT. See LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_captcha_kit-1.0.2.tar.gz.
File metadata
- Download URL: django_captcha_kit-1.0.2.tar.gz
- Upload date:
- Size: 27.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: pdm/2.28.0 CPython/3.14.6 Linux/6.17.0-1020-azure
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d57c0274a42e08e5b4773d1f5a70020096b258b76a952c49333b8da4fa3f24c8
|
|
| MD5 |
4c4ee5f21ee4a6ab5e33e71d55826efe
|
|
| BLAKE2b-256 |
2bc9a33db12efc51fe43a80a26fac07d102ae27a590ece399d4caa0f1f5d969f
|
File details
Details for the file django_captcha_kit-1.0.2-py3-none-any.whl.
File metadata
- Download URL: django_captcha_kit-1.0.2-py3-none-any.whl
- Upload date:
- Size: 24.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: pdm/2.28.0 CPython/3.14.6 Linux/6.17.0-1020-azure
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fd65cab99cee4339331efa8fdb5fd3d45cafc35e76c53ae9596361f2540947ce
|
|
| MD5 |
ab4f3015b7e8996c1cc46fda3bb49b77
|
|
| BLAKE2b-256 |
7aaf75e78842b986fe92e42a221ccd3db78a018b83670a2e1fb9cf042bce7a6e
|