How to Verify Cloudflare Turnstile Tokens Server-Side in a Python App
Learn how to verify Cloudflare Turnstile bot-protection tokens server-side in Python, using Cloudflare's own dummy test keys so every example runs against the real API without needing an account.
If your site has a contact form, a signup page, or a comment box, something out there is probably trying to submit it automatically, thousands of times a minute, looking for a form that will accept spam, test stolen credit cards, or scrape a discount code. The usual defense is a CAPTCHA: a puzzle that is supposed to be easy for a human and hard for a bot. Cloudflare Turnstile is a newer alternative that skips the puzzle entirely. It runs invisible browser checks instead, and only asks a visitor to click a checkbox (or nothing at all) when it needs more signal.
Table Of Content
- What You Will Build
- Prerequisites
- Step 1: How Turnstile Actually Verifies a Visitor
- Step 2: Write a Reusable Verification Function
- Step 3: The Bug Almost Everyone Writes First
- Step 4: A Second, Subtler Gotcha
- Step 5: A Safety Net for Production
- Step 6: Wire Verification Into a Real Endpoint
- Step 7: Test the Whole Flow End to End
- Common Mistakes to Watch For
- How to Confirm It All Works
- Next Steps
In this tutorial you will build the part of Turnstile that actually matters for security: the server-side check. A widget on your page is not protection by itself, since an attacker can simply skip your JavaScript and POST straight to your form handler. The real protection happens when your backend calls Cloudflare’s own verification API and only proceeds if that API says the visitor is real. You will write that verification code, wire it into a small web app, and then deliberately break it a few different ways to see exactly what a broken check looks like.
Everything in this tutorial runs against Cloudflare’s real, live API. You will not need a Cloudflare account, a domain, or a credit card, because Cloudflare publishes a set of fixed test keys specifically so developers can build and test an integration like this one without touching production credentials. Every request in this tutorial is a real HTTP call and a real response, not a simulation.
What You Will Build
By the end of this tutorial you will have a small, reusable verify_turnstile_token() function, a FastAPI endpoint that uses it to protect a contact form, and a pytest suite that exercises the whole thing against Cloudflare’s real API. Along the way you will personally trigger and fix two realistic bugs: one where a broken check silently lets every forged submission through, and one where a “successful” test does not actually prove what it looks like it proves.
Prerequisites
- Python 3.10 or later (this tutorial was built and tested on Python 3.13).
- Basic familiarity with HTTP requests and JSON. No prior Cloudflare or CAPTCHA experience is assumed; every concept is defined before it is used.
- A terminal, and the ability to create a virtual environment and install packages with pip.
- Outbound internet access, since several steps make real HTTPS requests to Cloudflare’s API.
- No Cloudflare account, API token, or domain is required.
Set up an isolated environment and install the packages this tutorial uses:
python -m venv venv
venv\Scripts\activate # on Windows
# source venv/bin/activate # on macOS/Linux
pip install fastapi uvicorn python-multipart pytest httpx requests
This tutorial used fastapi 0.141.1, requests 2.34.2, and pytest 9.1.1. All of them install from prebuilt wheels, so no C compiler is needed.
Step 1: How Turnstile Actually Verifies a Visitor
Turnstile works in two separate steps, and it is important to understand both before writing any code, because the whole security model depends on which step you can trust.
Step A, in the browser: your page embeds a small widget with a data-sitekey attribute. Cloudflare’s script runs a battery of checks (things like whether the browser behaves like a normal browser, not whether the visitor solved a puzzle) and, when it is satisfied, hands your page a one-time token: a long opaque string. Your form includes that token as a hidden field when it submits.
<!-- goes in your page's HTML, inside the <form> -->
<div class="cf-turnstile" data-sitekey="1x00000000000000000000AA"></div>
That sitekey, 1x00000000000000000000AA, is not a placeholder you need to replace before this will work. It is one of Cloudflare’s own public, permanent test sitekeys, and it is documented to always pass and to work from any domain, including localhost. When a page uses it, Cloudflare’s widget always hands back the exact same fixed token: XXXX.DUMMY.TOKEN.XXXX. That fixed value is what makes the rest of this tutorial possible without a browser: you already know exactly what a passing token looks like, because Cloudflare tells you in advance.
Step B, on your server: this is the step that actually matters, and it is the one this tutorial focuses on. Your backend takes that token and POSTs it to Cloudflare’s Siteverify API, a public endpoint at https://challenges.cloudflare.com/turnstile/v0/siteverify, along with a secret key that only your server knows. Cloudflare checks whether that token is real, unexpired, and not already used, and replies with a JSON verdict. Only after your server sees a genuine “yes” should it treat the submission as coming from a real visitor.
The reason Step B has to happen on the server, and cannot be skipped, is that Step A is not something you can trust on its own. Nothing stops an attacker from ignoring your page entirely and sending a POST request directly to your form’s URL with a fake or empty token. If your server does not call Siteverify and check the answer, that fake submission looks identical to a real one.
Step 2: Write a Reusable Verification Function
Create verify_turnstile.py. This one function is the entire security boundary for this tutorial: everything else just calls it and reacts to what it returns.
# verify_turnstile.py
import requests
SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
class TurnstileError(Exception):
"""Raised when the Siteverify call itself is broken: unreachable, or a
bad secret key. Never raised just because a visitor's token was rejected,
since a rejected token is a normal, expected outcome, not a bug."""
def verify_turnstile_token(token, secret_key, remote_ip=None, timeout=5):
if not token:
return {"success": False, "error-codes": ["missing-input-response"]}
payload = {"secret": secret_key, "response": token}
if remote_ip:
payload["remoteip"] = remote_ip
try:
response = requests.post(SITEVERIFY_URL, data=payload, timeout=timeout)
except requests.RequestException as exc:
raise TurnstileError(f"could not reach Siteverify API: {exc}") from exc
try:
result = response.json()
except ValueError as exc:
raise TurnstileError(
f"Siteverify API returned a non-JSON body: {response.text[:200]!r}"
) from exc
if response.status_code >= 400:
raise TurnstileError(
f"Siteverify rejected the request itself (HTTP {response.status_code}): "
f"{result.get('error-codes')}"
)
return result
A few deliberate choices here, explained up front so the rest of the tutorial makes sense:
- An empty token is rejected locally, before any network call. Cloudflare’s API would reject it too, but there is no reason to spend a round trip proving that.
- A short
timeoutis set on every request. Without one, a slow or unreachable Siteverify endpoint would hang your form handler indefinitely, turning a third-party outage into an outage of your own site. - The function distinguishes two very different kinds of failure: a rejected token (a normal outcome your code should handle every day) versus a broken request, like a malformed secret key (a configuration bug on your side that should be loud, not silently treated like a rejected visitor). You will see exactly why this split matters in Step 4.
Now call it. Create step1_basic_calls.py:
# step1_basic_calls.py
from verify_turnstile import verify_turnstile_token
DUMMY_TOKEN = "XXXX.DUMMY.TOKEN.XXXX"
ALWAYS_PASSES = "1x0000000000000000000000000000000AA"
ALWAYS_FAILS = "2x0000000000000000000000000000000AA"
ALREADY_SPENT = "3x0000000000000000000000000000000AA"
for label, secret in [
("always-passes test secret", ALWAYS_PASSES),
("always-fails test secret", ALWAYS_FAILS),
("already-spent test secret", ALREADY_SPENT),
]:
result = verify_turnstile_token(DUMMY_TOKEN, secret)
print(f"{label:28} success={result['success']!s:6} error-codes={result.get('error-codes')}")
These three secret keys, like the sitekey from Step 1, are permanent, public, and documented by Cloudflare specifically for testing. Each one is scripted to always produce the same answer, which is exactly what makes automated tests reliable: a real visitor’s pass/fail result can vary, but a test key’s cannot. Run it:
python step1_basic_calls.py
Real output from this exact run:
always-passes test secret success=True error-codes=[]
always-fails test secret success=False error-codes=['invalid-input-response']
already-spent test secret success=False error-codes=['timeout-or-duplicate']
That third result is worth pausing on. timeout-or-duplicate is the same error code Cloudflare returns for two unrelated real-world situations: a token that expired (tokens are only valid for 300 seconds after they are issued) and a token that was already redeemed once (each token can only be verified successfully one time, which stops an attacker from capturing a valid token and replaying it on multiple requests). Your code cannot tell those two cases apart from the error code alone, and for security purposes it does not need to: either way, the right response is the same, reject the request and ask the visitor to try again.
Step 3: The Bug Almost Everyone Writes First
Here is the single most common mistake in a homegrown Turnstile integration, reproduced for real. Create step2_status_code_bug.py:
# step2_status_code_bug.py
import requests
DUMMY_TOKEN = "XXXX.DUMMY.TOKEN.XXXX"
ALWAYS_FAILS = "2x0000000000000000000000000000000AA"
response = requests.post(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
data={"secret": ALWAYS_FAILS, "response": DUMMY_TOKEN},
timeout=5,
)
print("HTTP status code:", response.status_code)
print("Body:", response.json())
print()
if response.status_code == 200:
print("Naive check ('if status_code == 200') says: ALLOW the request")
print("... but this token was just told to ALWAYS FAIL. That's the bug.")
Run it:
python step2_status_code_bug.py
Real output:
HTTP status code: 200
Body: {'error-codes': ['invalid-input-response'], 'success': False, 'messages': [], 'metadata': {'result_with_testing_key': True}}
Naive check ('if status_code == 200') says: ALLOW the request
... but this token was just told to ALWAYS FAIL. That's the bug.
The Siteverify API returns HTTP 200 for a rejected token. It always has a JSON body to hand back, so it uses a normal 200 response and puts the actual verdict inside the body, in the success field. If your code checks response.status_code == 200 and treats that as “the visitor passed,” you have built a bot-verification system that lets every forged and expired token straight through, while looking, to a casual glance, like it works. The only field that tells you the truth is result["success"]. The verify_turnstile_token() function from Step 2 already returns the full parsed body specifically so the caller is forced to look at that field instead of the status code.
Step 4: A Second, Subtler Gotcha
It is tempting to think “I tested it with the always-passes key and it worked, so my integration is solid.” Run this and see why that is not enough proof. Create step3_test_key_blind_spot.py:
# step3_test_key_blind_spot.py
from verify_turnstile import verify_turnstile_token
ALWAYS_PASSES = "1x0000000000000000000000000000000AA"
print("--- does the 'always passes' secret actually look at the token? ---")
for label, token in [
("the documented dummy token", "XXXX.DUMMY.TOKEN.XXXX"),
("a garbage string", "not-a-real-token-at-all"),
]:
result = verify_turnstile_token(token, ALWAYS_PASSES)
print(f"{label:32} -> success={result['success']}")
print()
print("--- what happens with a missing or malformed SECRET key? ---")
for label, secret in [
("empty secret (unset env var)", ""),
("malformed secret (typo'd env var)", "not-a-real-secret-key"),
]:
try:
verify_turnstile_token("XXXX.DUMMY.TOKEN.XXXX", secret)
print(f"{label:34} -> no exception raised (unexpected)")
except Exception as exc:
print(f"{label:34} -> raised {type(exc).__name__}: {exc}")
Run it:
python step3_test_key_blind_spot.py
Real output:
--- does the 'always passes' secret actually look at the token? ---
the documented dummy token -> success=True
a garbage string -> success=True
--- what happens with a missing or malformed SECRET key? ---
empty secret (unset env var) -> raised TurnstileError: Siteverify rejected the request itself (HTTP 400): ['missing-input-secret']
malformed secret (typo'd env var) -> raised TurnstileError: Siteverify rejected the request itself (HTTP 400): ['invalid-input-secret']
Look at the first two lines. The always-passes test secret returns success=True even when the token itself is a garbage string that no real widget ever generated. That secret key’s entire job is to always say yes, no matter what you send it. That is genuinely useful for testing the rest of your application (does a passing submission actually get processed correctly?), but it proves nothing about whether your code correctly rejects a bad token, because this particular test key never rejects anything. If you only ever test with the always-passes key, you could ship a completely broken verification function (one that, say, never even calls the Siteverify API and just returns success: True unconditionally) and every test would still pass. The always-fails key from Step 3 is the one that actually proves your rejection path works.
The second half of the output shows a different, equally important distinction. A missing or malformed secret key returns HTTP 400, not 200. This is exactly the split verify_turnstile_token() was built around: token-related failures (rejected, expired, already used) come back as HTTP 200 with success: false, while request-level failures (your secret key itself is missing or wrong) come back as HTTP 400. A misconfigured deployment, like an unset environment variable, is a bug in your infrastructure, and it deserves a loud exception that pages someone, not a quiet “the visitor failed the bot check” that gets logged and ignored while your form silently rejects every legitimate visitor.
Step 5: A Safety Net for Production
There is one more field in the response worth knowing about: metadata.result_with_testing_key. It is true whenever the answer came from one of Cloudflare’s fixed test keys, and absent otherwise. You can use it as a canary: a check that should never fire in production, and that tells you something has gone wrong if it ever does.
Create a companion helper for verify_turnstile.py (append this to the bottom of the same file):
# add to the bottom of verify_turnstile.py
def is_using_test_key(result):
return bool(result.get("metadata", {}).get("result_with_testing_key"))
Then create step4_production_safety_canary.py:
# step4_production_safety_canary.py
from verify_turnstile import is_using_test_key, verify_turnstile_token
ALWAYS_PASSES = "1x0000000000000000000000000000000AA"
def check_deploy_is_safe(secret_key):
"""Call this once at startup against a known-good dummy token in a
staging smoke test, or fail a deploy if it ever fires in production."""
probe = verify_turnstile_token("XXXX.DUMMY.TOKEN.XXXX", secret_key)
if is_using_test_key(probe):
raise RuntimeError(
"This secret key is one of Cloudflare's TEST keys. "
"If you are seeing this outside local development, "
"a test credential leaked into a real deploy."
)
print("OK: secret key is not a Cloudflare test key.")
print("Probing with a real TEST secret key (should raise):")
try:
check_deploy_is_safe(ALWAYS_PASSES)
except RuntimeError as exc:
print(f" caught expected RuntimeError: {exc}")
Run it:
python step4_production_safety_canary.py
Real output:
Probing with a real TEST secret key (should raise):
caught expected RuntimeError: This secret key is one of Cloudflare's TEST keys. If you are seeing this outside local development, a test credential leaked into a real deploy.
Wiring a check like this into a deploy pipeline or startup health check is cheap insurance against a real, easy-to-make mistake: copying a .env.test file into a production environment, or forgetting to swap a test secret for a real one before going live. Without a check like this, that mistake looks completely fine, since the always-passes key will happily let every visitor through, bot or not, and nothing will look broken until someone notices the spam.
Step 6: Wire Verification Into a Real Endpoint
Now put all of this behind an actual HTTP endpoint. Create app.py:
# app.py
import os
from fastapi import FastAPI, Form, HTTPException
from verify_turnstile import TurnstileError, verify_turnstile_token
app = FastAPI()
TURNSTILE_SECRET_KEY = os.environ.get("TURNSTILE_SECRET_KEY", "")
@app.post("/contact")
def submit_contact_form(
name: str = Form(...),
message: str = Form(...),
cf_turnstile_response: str = Form("", alias="cf-turnstile-response"),
):
if not TURNSTILE_SECRET_KEY:
raise HTTPException(
status_code=500,
detail="server misconfigured: TURNSTILE_SECRET_KEY is not set",
)
try:
result = verify_turnstile_token(cf_turnstile_response, TURNSTILE_SECRET_KEY)
except TurnstileError as exc:
# A bad secret key is a bug on our side, not a rejected visitor.
# Fail closed, but say so with a status code that points at us.
raise HTTPException(status_code=502, detail=str(exc)) from exc
if not result.get("success"):
raise HTTPException(
status_code=403,
detail=f"bot check failed: {result.get('error-codes')}",
)
return {"status": "sent", "name": name}
Two details worth calling out. First, the form field is named cf-turnstile-response with hyphens, because that is the literal field name the real Turnstile widget uses when it submits a form; FastAPI cannot use a hyphenated name as a Python parameter name, so alias="cf-turnstile-response" bridges the two. Second, notice the three different status codes chosen on purpose: 500 means our own server forgot to configure a secret key at all, 502 means our secret key is present but Cloudflare rejected the request itself (also our bug, but a different one), and 403 means the request reached Cloudflare fine and Cloudflare said the visitor failed the check. A monitoring dashboard that only tracks “error rate” would blur all three together; splitting them means a spike in 403s (probably just bots being bots) looks completely different from a spike in 500s or 502s (a deploy just broke something).
Start the server and confirm it is alive:
export TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA # macOS/Linux
$env:TURNSTILE_SECRET_KEY = "1x0000000000000000000000000000000AA" # Windows PowerShell
uvicorn app:app --reload
In a second terminal, send a request with the documented dummy token:
curl -X POST http://127.0.0.1:8000/contact \
-d "name=Ada" -d "message=hello" -d "cf-turnstile-response=XXXX.DUMMY.TOKEN.XXXX"
Real output:
{"status":"sent","name":"Ada"}
Now try it with a token that will not pass, by restarting the server with TURNSTILE_SECRET_KEY set to the always-fails key (2x0000000000000000000000000000000AA) and sending the same request:
{"detail":"bot check failed: ['invalid-input-response']"}
with an HTTP 403 status. The form correctly refuses to process the submission.
Step 7: Test the Whole Flow End to End
Manually restarting the server with different environment variables works, but it is slow and easy to forget a case. Create test_app.py instead, using monkeypatch to swap the secret key per test:
# test_app.py
import app as app_module
from fastapi.testclient import TestClient
DUMMY_TOKEN = "XXXX.DUMMY.TOKEN.XXXX"
ALWAYS_PASSES = "1x0000000000000000000000000000000AA"
ALWAYS_FAILS = "2x0000000000000000000000000000000AA"
client = TestClient(app_module.app)
def test_valid_token_is_accepted(monkeypatch):
monkeypatch.setattr(app_module, "TURNSTILE_SECRET_KEY", ALWAYS_PASSES)
response = client.post(
"/contact",
data={"name": "Ada", "message": "hello", "cf-turnstile-response": DUMMY_TOKEN},
)
assert response.status_code == 200
assert response.json() == {"status": "sent", "name": "Ada"}
def test_rejected_token_is_blocked(monkeypatch):
monkeypatch.setattr(app_module, "TURNSTILE_SECRET_KEY", ALWAYS_FAILS)
response = client.post(
"/contact",
data={"name": "Eve", "message": "let me in", "cf-turnstile-response": DUMMY_TOKEN},
)
assert response.status_code == 403
assert "invalid-input-response" in response.json()["detail"]
def test_missing_token_field_is_rejected_without_a_network_call(monkeypatch):
monkeypatch.setattr(app_module, "TURNSTILE_SECRET_KEY", ALWAYS_PASSES)
response = client.post("/contact", data={"name": "Mallory", "message": "no captcha here"})
assert response.status_code == 403
assert "missing-input-response" in response.json()["detail"]
def test_misconfigured_secret_fails_closed_with_502(monkeypatch):
monkeypatch.setattr(app_module, "TURNSTILE_SECRET_KEY", "not-a-real-secret-key")
response = client.post(
"/contact",
data={"name": "Carol", "message": "hi", "cf-turnstile-response": DUMMY_TOKEN},
)
assert response.status_code == 502
def test_unset_secret_key_returns_500(monkeypatch):
monkeypatch.setattr(app_module, "TURNSTILE_SECRET_KEY", "")
response = client.post(
"/contact",
data={"name": "Bob", "message": "hi", "cf-turnstile-response": DUMMY_TOKEN},
)
assert response.status_code == 500
Run the suite:
pytest test_app.py -v
Real output:
test_app.py::test_valid_token_is_accepted PASSED [ 20%]
test_app.py::test_rejected_token_is_blocked PASSED [ 40%]
test_app.py::test_missing_token_field_is_rejected_without_a_network_call PASSED [ 60%]
test_app.py::test_misconfigured_secret_fails_closed_with_502 PASSED [ 80%]
test_app.py::test_unset_secret_key_returns_500 PASSED [100%]
5 passed, 1 warning in 1.27s
Every one of these tests makes a real network call to Cloudflare’s live API. That might look unusual since most guides teach you to mock third-party calls in tests, but Cloudflare designed these specific keys to make that unnecessary here: their own documentation describes them as existing precisely so “automated testing suites (like Selenium, Cypress, or Playwright)” can get predictable, controlled responses without a mock in the way. Mocking is still the right call for most external APIs; this is a deliberate exception because the vendor built and guarantees a stable seam for exactly this purpose. If you would rather not depend on network access during tests at all, the same five scenarios can be reproduced by mocking requests.post to return the exact JSON bodies captured in Steps 2 through 4 above.
Common Mistakes to Watch For
- Checking the HTTP status code instead of the
successfield. A rejected token still comes back as HTTP 200. Onlyresult["success"]tells you the truth. See Step 3. - Trusting a passing test run without also testing the rejection path. The always-passes key ignores the token entirely. Test with the always-fails key too, or you are only proving your happy path exists, not that your rejection logic works. See Step 4.
- Treating a bad secret key the same as a rejected visitor. A missing or malformed secret returns HTTP 400, a distinct failure mode from a rejected token’s HTTP 200. Conflating the two hides real outages behind “just some bot traffic” in your logs.
- Skipping the timeout on the outbound request. If Cloudflare’s API is ever slow or unreachable and your code has no timeout, every form submission on your site hangs until it times out at the TCP level, which can take minutes.
- Only implementing Step A (the widget) and never calling Siteverify. This is the most serious mistake of all: a widget with no server-side check protects nothing, since any attacker can bypass your page’s JavaScript entirely.
How to Confirm It All Works
Run through this checklist against your own copy before considering the integration done:
pytest test_app.py -vshows all five tests passing.- A real request with the always-passes secret and the documented dummy token returns HTTP 200 and
{"status": "sent", ...}. - The same request with the always-fails secret returns HTTP 403, not 200.
- A request with no
cf-turnstile-responsefield at all is rejected with HTTP 403, with zero network calls made (you can confirm this by watching your own request logs or a packet capture; the empty-token short-circuit inverify_turnstile_token()means Siteverify is never contacted). - An unset or malformed
TURNSTILE_SECRET_KEYproduces a 500 or 502, not a silent pass-through.
Once every one of those checks matches, swap the dummy sitekey in your real page’s HTML and the dummy secret key in your server’s environment variables for the real pair from your own Cloudflare dashboard (Turnstile is free to add to any site), and the same verification code keeps working unchanged.
Next Steps
Turnstile verification is one layer in a broader defense against automated abuse. If you want to keep building on the concepts in this tutorial, these related guides on sxz.io cover adjacent ground:
- How to Prevent Server-Side Request Forgery (SSRF) in a Python Web App, another case where a request that looks legitimate needs a second, server-side check before your backend trusts it.
- How to Stop CSRF Attacks in a Python Web App With Synchronizer Tokens, a different single-use-token pattern protecting a different part of the same request lifecycle.
- How to Stop XSS Attacks in a Python Web App With a Content Security Policy, for locking down what a page is allowed to run once a visitor, real or not, reaches it.
- How to Prevent SQL Injection in Python With Parameterized Queries, for the step right after this one: safely handling whatever a verified visitor actually submits.
For the authoritative reference on every field in the Siteverify response and the full list of error codes, see Cloudflare’s own server-side validation documentation, and for the complete, current list of test sitekeys and secret keys used throughout this tutorial, see Cloudflare’s testing guide.








No Comment! Be the first one.