Stop form spam in Django

Updated

This guide adds Bot403 to a contact form in Django. The browser solves a small proof of work. Then the view checks the token with one call, before it accepts the message. The visitor sees no puzzle, and the script sets no cookie.

The example uses Django 6.1 on Python 3.15 and the requests library. It was run on 23 October 2026 against a local copy of the verify API. The tests covered a good token, a reused token, an expired token and a token of another site. The tests also covered a filled honeypot, a capped month and an API that does not answer. The two tests in the "Test it" section were also run.

Before you start

Sign in. Create a site. Copy its public key (pk_) and its secret key (sk_). Put the secret key in an environment variable. Never put it in settings.py in your repository.

Terminal
uv add django requests        # or: pip install django requests
export BOT403_SECRET_KEY=sk_live_YOUR_SECRET_KEY

1. The template

Add the app (contact in this example) to INSTALLED_APPS, so that Django can find the template. The script tag protects every form on the page. Before the browser sends the form, the script adds a hidden _bot403 field with the token.

contact/templates/contact.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Contact</title>
  <script src="https://bot403.com/v1/bot403.js" data-bot403-key="pk_live_YOUR_PUBLIC_KEY" async></script>
</head>
<body>
  <form method="post" action="{% url 'contact' %}">
    {% csrf_token %}
    <label>Email <input name="email" type="email" required></label>
    <label>Message <textarea name="message" required></textarea></label>
    <button type="submit">Send</button>
  </form>
</body>
</html>

2. The view

The view sends the form fields to Bot403. It accepts the message only when the answer is ok: true. If Bot403 does not answer in 5 seconds, the view accepts the form and logs the error.

contact/views.py
import logging
import os

import requests
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import redirect, render

log = logging.getLogger(__name__)


def bot403_check(post):
    # 1. Post the whole form. Bot403 reads the _bot403 field and the honeypot field.
    #    The CSRF token stays on your server.
    fields = {k: v for k, v in post.items() if k != "csrfmiddlewaretoken"}
    try:
        r = requests.post(
            "https://bot403.com/api/v1/verify",
            headers={"Authorization": "Bearer " + os.environ["BOT403_SECRET_KEY"]},
            data=fields,
            timeout=5,
        )
        result = r.json()
    except (requests.RequestException, ValueError):
        log.exception("bot403 verify failed, letting the form through")
        return True
    # 2. Refuse when ok is false, except for reason "quota"
    return result["ok"] or result.get("reason") == "quota"


def contact(request):
    if request.method == "POST":
        if not bot403_check(request.POST):
            return HttpResponseBadRequest("Try again")
        # A real visitor: send the email or store the message here
        return redirect("thanks")
    return render(request, "contact.html")


def thanks(request):
    return HttpResponse("Thanks. Your message is on its way.")
mysite/urls.py
from django.urls import path

from contact import views

urlpatterns = [
    path("contact/", views.contact, name="contact"),
    path("thanks/", views.thanks, name="thanks"),
]

The trap: CSRF, and why csrf_exempt is never the fix

If a POST has no CSRF token, Django refuses it with 403, before your view runs. For this reason, the template keeps {% csrf_token %}. If a spam test fails with 403, do not add @csrf_exempt to the view. CSRF stops other sites that send forms in the name of your visitor, and Bot403 does not do that job. It only stops scripts that send forms automatically.

This has three results:

  • The view removes csrfmiddlewaretoken before it sends the form fields. As a result, the CSRF token stays on your server.
  • A test with curl gets 403 from the CSRF check. Use the Django test client, which skips this check, as in the "Test it" section.
  • If you send the form with fetch, build the body from the form. Then the body keeps the CSRF field. In your code, get the token from the script:
contact.js
const form = document.querySelector("form");
form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const body = new FormData(form); // holds csrfmiddlewaretoken from {% csrf_token %}
  body.set("_bot403", await window.Bot403.solve());
  const res = await fetch(form.action, { method: "POST", body });
  if (res.ok) location.href = res.url;
});

Test it

The tests replace the call to Bot403 with a fixed answer. As a result, the tests need no network and no key:

contact/tests.py
from unittest import mock

from django.test import TestCase


def answer(**body):
    return mock.Mock(**{"json.return_value": {"site": "pk_live_test", **body}})


@mock.patch.dict("os.environ", {"BOT403_SECRET_KEY": "sk_test"})
class ContactTests(TestCase):
    @mock.patch("contact.views.requests.post", return_value=answer(ok=True))
    def test_verified_form_goes_through(self, post):
        r = self.client.post("/contact/", {"email": "[email protected]", "_bot403": "token"})
        self.assertRedirects(r, "/thanks/", fetch_redirect_response=False)
        self.assertEqual(post.call_args.kwargs["data"]["_bot403"], "token")

    @mock.patch("contact.views.requests.post", return_value=answer(ok=False, reason="invalid"))
    def test_failed_token_is_refused(self, post):
        r = self.client.post("/contact/", {"email": "[email protected]"})
        self.assertEqual(r.status_code, 400)
Terminal
python manage.py test contact
# Ran 2 tests in 0.004s
# OK

Then run python manage.py runserver. Open /contact/. Send the form. The browser goes to /thanks/, and the site in your dashboard shows one check. If you get a failure that you do not expect, read the troubleshooting page. It lists every reason and its fix.