In Django, the form says no
and the view never finds out.
A ValidationError raised in clean() rejects the registration through the machinery Django already has: form_invalid() re-renders with the error, the user row is never written, and CreateView stays three lines long. The same shape works in plain Django and in django-allauth.
01The form
# forms.py (pip install requests — Django bundles no HTTP client)
import json
import requests
from django.conf import settings
from django.contrib.auth.forms import UserCreationForm
from django import forms
def verify_human(user_id: str, session_data: dict) -> dict | None:
"""Returns the useHUMA verdict, or None on outage (fail open)."""
try:
resp = requests.post(
"https://humaverify.com/api/v1/verify",
json={"userId": user_id, "sessionData": session_data},
headers={"Authorization": f"Bearer {settings.HUMA_API_KEY}"},
# (connect, read). Without this, requests waits FOREVER and a
# useHUMA outage becomes a hung signup page.
timeout=(3.05, 5),
)
resp.raise_for_status()
return resp.json()
except requests.RequestException:
return None # our outage must not become your outage
class RegistrationForm(UserCreationForm):
# required=False on purpose: JS-disabled browsers and email-scanner
# prefetches submit it empty, and absence is not evidence of a bot.
huma_signals = forms.CharField(widget=forms.HiddenInput, required=False)
def clean(self):
cleaned_data = super().clean()
try:
signals = json.loads(cleaned_data.get("huma_signals") or "{}")
except (TypeError, ValueError):
signals = {}
verdict = verify_human(
user_id=cleaned_data.get("username", "anonymous"),
session_data=signals,
)
# .get with a True default: an unexpected 200 body fails open
# rather than raising KeyError in the middle of a signup.
if verdict is not None and not verdict.get("human", True):
raise forms.ValidationError(
"We couldn't verify you're human. Please try again.",
code="huma_not_human",
)
return cleaned_data02The view, unchanged
# views.py — nothing special: rejection happens in the form
from django.urls import reverse_lazy
from django.views.generic.edit import CreateView
from .forms import RegistrationForm
class SignUpView(CreateView):
form_class = RegistrationForm
template_name = "registration/signup.html"
success_url = reverse_lazy("login")03The template carries the signals
<!-- templates/registration/signup.html -->
<script src="https://humaverify.com/huma.js"></script>
<form method="post">{% csrf_token %}{{ form.as_p }}
<button>Sign up</button>
</form>
<script>
document.querySelector("form").addEventListener("submit", function () {
var f = document.querySelector("[name=huma_signals]");
if (window.Huma && f) f.value = JSON.stringify(window.Huma.debug().features);
});
</script>04The allauth variant
# The django-allauth variant, which is what most real signup flows use.
# forms.py
from allauth.account.forms import SignupForm as AllauthSignupForm
class HumaSignupForm(AllauthSignupForm):
huma_signals = forms.CharField(widget=forms.HiddenInput, required=False)
def clean(self):
# super().clean() FIRST: email and password uniqueness should fail
# before you spend a verification call.
cleaned_data = super().clean()
try:
signals = json.loads(cleaned_data.get("huma_signals") or "{}")
except (TypeError, ValueError):
signals = {}
verdict = verify_human(cleaned_data.get("email", "anonymous"), signals)
if verdict is not None and not verdict.get("human", True):
raise forms.ValidationError("We couldn't verify you're human.")
return cleaned_data
# settings.py
HUMA_API_KEY = os.environ["HUMA_API_KEY"]
ACCOUNT_FORMS = {"signup": "accounts.forms.HumaSignupForm"}What bites
- An unset timeout is the real danger. Django bundles no HTTP client, and
requestswithout a timeout waits forever. The(3.05, 5)tuple caps a full-outage signup at roughly eight seconds and the fail-open path handles the rest. - Reject in
clean(), not in the view.form_valid()only runs afteris_valid(), so the form is the one place the rejection composes with everything else Django does. verdict.get("human", True), notverdict["human"]: an unexpected 200 body should fail open, not raise KeyError mid-signup.- With allauth, use
ACCOUNT_FORMSand do not also set the olderACCOUNT_SIGNUP_FORM_CLASS. They are separate mechanisms and incompatible. - Call
super().clean()before the check so uniqueness validation runs before you spend an API call. - The call is synchronous inside the request. If your signup runs under ASGI with async views, this sync form validation still works, but do not copy the pattern into an async-only code path without adapting it.
Why this instead of a CAPTCHA
A CAPTCHA charges every legitimate user for the existence of bots that increasingly solve puzzles anyway. This charges nobody: the person types, and the decision happens on your server from what already happened. It also answers a question a CAPTCHA does not, which is whether the session was an AI agent driving a real browser rather than a script. What it does not catch is written down at /limits, before you build on it.
Django for the app, useHUMA for humanity.
Free plan: 1,000 verifications a month · no card · no expiry · AI-agent verdict included.
Get your API key →Form, view and allauth facts on this page were checked against Django's and django-allauth's live documentation on 26 Aug 2026, for Django 5.2 LTS, and are identical on 6.0. If something has drifted, tell us at team@humaverify.com and it gets fixed.