Package Information
Available Nodes
Documentation generating…
Documentation
n8n-nodes-cloudflare-turnstile
An n8n community node that verifies Cloudflare Turnstile tokens on the server side.
Typical use case: a website form (Webflow, WordPress, plain HTML, React, …) posts its data to an n8n Webhook. Before the workflow sends emails, creates CRM entries or stores leads, this node asks Cloudflare whether the submission really came from a human. Valid submissions continue, everything else is routed to a separate output, so you can reject it.
Browser form ──(form data + Turnstile token)──▶ n8n Webhook
│
▼
Cloudflare Turnstile node ──▶ Cloudflare siteverify API
│ │
Valid Invalid
│ │
your workflow Respond to Webhook (403)
Contents
- Features
- Installation
- Cloudflare setup
- Credentials
- Node reference
- Output
- Error codes
- Example workflow
- Frontend integration
- Testing
- Troubleshooting
- Security notes
- Compatibility
- Development
- Resources
- Version history
Features
- Two outputs, Valid and Invalid, so you don't need an extra IF node
- Passes the input item through unchanged and adds the verification result to it, so later nodes can still read the form data
- Optional hostname check, so tokens issued on other domains are rejected
- Optional action check for setups with several widgets that use different
actionvalues - Forwards the visitor IP to Cloudflare, which improves detection
- Idempotency key support, so retries can verify the same token again
- Credential test that tells you right away whether the secret key is valid
- Misconfiguration fails loudly. A wrong secret key stops the workflow with a clear error instead of silently rejecting every visitor.
Installation
Via the n8n UI (self-hosted)
- Go to Settings → Community Nodes.
- Click Install.
- Enter
n8n-nodes-cloudflare-turnstileand confirm.
The node then shows up as Cloudflare Turnstile in the nodes panel. See the n8n community nodes installation guide for details.
Manually (Docker)
docker exec -u node <n8n-container> sh -c \
"mkdir -p ~/.n8n/nodes && cd ~/.n8n/nodes && npm install n8n-nodes-cloudflare-turnstile"
docker restart <n8n-container>
Make sure /home/node/.n8n is a persistent volume. Otherwise the node is gone after the container is recreated.
Queue mode: If you run n8n with separate worker containers, install the package on the main instance and on every worker.
Cloudflare setup
Open the Cloudflare dashboard → Turnstile → Add widget.
Enter a name and add all hostnames the form runs on, e.g.
example.com,www.example.comandexample.webflow.io.Choose a widget mode:
Mode Behaviour Managed (recommended) Cloudflare decides per visitor. Most visitors pass automatically, and suspicious ones get a checkbox. Non-interactive Never asks for interaction. The widget is visible with a loading indicator. Invisible Never visible and never interactive. Copy the site key, which is public and goes into the website, and the secret key, which is private and goes into n8n.
Credentials
Create a credential of type Cloudflare Turnstile API:
| Field | Description |
|---|---|
| Secret Key | The secret key of your Turnstile widget (Turnstile → widget → Settings) |
When you save the credential, n8n sends a test request to Cloudflare. If the key is rejected, you see Invalid secret key.
You can verify tokens of several widgets (e.g. different websites) by creating one credential per widget.
Node reference
Parameters
| Parameter | Default | Description |
|---|---|---|
| Turnstile Response | {{ $json.body["cf-turnstile-response"] }} |
The token the widget generated in the browser. The default matches the output of n8n's Webhook node when the form field is named cf-turnstile-response, which is the Turnstile default. |
Options
| Option | Default | Description |
|---|---|---|
| Expected Hostnames | (empty) | Comma-separated list, e.g. example.com, www.example.com. If set, a valid token is only accepted when Cloudflare reports one of these hostnames. Otherwise the item goes to Invalid with hostname-mismatch. The comparison is case-insensitive. |
| Expected Action | (empty) | If set, the token is only accepted when the widget was rendered with exactly this action. Otherwise the error code is action-mismatch. |
| Remote IP | (empty) | IP address of the visitor. Optional, but improves validation. Behind Cloudflare use {{ $json.headers["cf-connecting-ip"] }}, behind other proxies e.g. {{ $json.headers["x-real-ip"] }}. Empty values are not sent. |
| Idempotency Key | (empty) | A UUID. A token can normally be verified only once, and with the same idempotency key Cloudflare allows repeated verification (useful for retries). |
| Result Field | turnstile |
Name of the field the verification result is written to. |
Behaviour
- Empty token: The item goes to Invalid with
missing-input-response. Cloudflare is not called. - Valid token: The item goes to Valid, provided the hostname and action checks pass if configured.
- Invalid, expired or reused token: The item goes to Invalid with the Cloudflare error codes.
- Wrong or missing secret key: The node fails with
Cloudflare rejected the secret key. With Settings → On Error → Continue the item goes to Invalid with anerrorfield instead. - Network or API error: The node fails, or with Continue On Fail the item goes to Invalid with an
errorfield. - Several items: Each item is verified separately and routed to its own output.
- Binary data on the input items is passed through.
Output
Both outputs contain the original input item plus the result field:
{
"headers": { "...": "..." },
"body": {
"email": "jane@example.com",
"message": "Hello!",
"cf-turnstile-response": "0.QrgXCw8Ia..."
},
"turnstile": {
"success": true,
"errorCodes": [],
"hostname": "example.com",
"challengeTs": "2026-10-21T08:03:16.357Z",
"action": "contact",
"cdata": "sessionid-123"
}
}
| Field | Description |
|---|---|
success |
true on the Valid output, false on the Invalid output |
errorCodes |
Cloudflare error codes plus hostname-mismatch / action-mismatch from this node |
hostname |
Hostname of the page the token was issued on |
challengeTs |
ISO timestamp of when the challenge was solved |
action |
The widget's action value, if set |
cdata |
The widget's cData value, if set |
Because the original item is passed through, expressions like {{ $('Webhook').item.json.body.email }} keep working in later nodes.
Error codes
| Code | Meaning | Typical cause |
|---|---|---|
missing-input-response |
No token was sent | The widget isn't on the form, the field name differs, or the form was submitted before the widget finished |
invalid-input-response |
The token is invalid | Forged or malformed token, or the token belongs to a different widget/secret |
timeout-or-duplicate |
The token has expired or was already verified | Tokens are valid for 300 seconds and only once. Reset the widget after each submission. |
bad-request |
The request to Cloudflare was malformed | Should not happen with this node |
internal-error |
Cloudflare had an internal error | Retry later |
hostname-mismatch |
(this node) The hostname is not in Expected Hostnames | Form used on an unexpected domain, or a missing entry in the list |
action-mismatch |
(this node) The action differs from Expected Action | Wrong widget, or a typo in the action |
missing-input-secret and invalid-input-secret are not routed to Invalid. They make the node fail, because they indicate a configuration error (see Behaviour).
Example workflow
Webhook (POST, Respond: "Using 'Respond to Webhook' Node")
└─ Cloudflare Turnstile
├─ Valid → Send Email → Respond to Webhook (200)
└─ Invalid → Respond to Webhook (403)
Set up the Webhook node like this:
- HTTP Method:
POST - Respond:
Using 'Respond to Webhook' Node. This lets the Invalid branch return a 403, so the frontend can show an error message. - Options → Allowed Origins (CORS): your website's domain(s)
Import this into n8n (copy and paste onto the canvas), then select your credential:
{
"nodes": [
{
"parameters": { "httpMethod": "POST", "path": "contact-form", "responseMode": "responseNode", "options": {} },
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [0, 0]
},
{
"parameters": {
"turnstileResponse": "={{ $json.body[\"cf-turnstile-response\"] }}",
"options": { "remoteIp": "={{ $json.headers[\"cf-connecting-ip\"] }}" }
},
"name": "Cloudflare Turnstile",
"type": "n8n-nodes-cloudflare-turnstile.cloudflareTurnstile",
"typeVersion": 1,
"position": [220, 0]
},
{
"parameters": { "respondWith": "json", "responseBody": "={{ { \"ok\": true } }}", "options": {} },
"name": "Success",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [460, -100]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { \"ok\": false, \"errors\": $json.turnstile.errorCodes } }}",
"options": { "responseCode": 403 }
},
"name": "Rejected",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [460, 100]
}
],
"connections": {
"Webhook": { "main": [[{ "node": "Cloudflare Turnstile", "type": "main", "index": 0 }]] },
"Cloudflare Turnstile": {
"main": [
[{ "node": "Success", "type": "main", "index": 0 }],
[{ "node": "Rejected", "type": "main", "index": 0 }]
]
}
}
}
In a real workflow, put your actual processing (email, CRM, database, …) between Valid and Success.
Frontend integration
The widget must be inside the form, and the token must reach the webhook as cf-turnstile-response. Replace YOUR_SITE_KEY and the webhook URL in the examples below.
Plain HTML form (classic form POST)
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<form action="https://n8n.example.com/webhook/contact-form" method="POST">
<input name="email" type="email" required>
<textarea name="message" required></textarea>
<!-- Turnstile adds a hidden input "cf-turnstile-response" -->
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
<button type="submit">Send</button>
</form>
With a classic form POST, the browser navigates to the webhook response. For a better user experience, use fetch as shown next.
JavaScript (fetch, JSON)
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<form id="contact">
<input name="email" type="email" required>
<textarea name="message" required></textarea>
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
<button type="submit">Send</button>
</form>
<script>
document.getElementById('contact').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.currentTarget;
const data = Object.fromEntries(new FormData(form)); // includes cf-turnstile-response
const res = await fetch('https://n8n.example.com/webhook/contact-form', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (res.ok) {
form.innerHTML = '<p>Thank you!</p>';
} else {
alert('Verification failed, please try again.');
turnstile.reset(); // tokens are single-use
}
});
</script>
Only show the widget when interaction is needed
Render the widget with appearance: "interaction-only", either as data-appearance="interaction-only" on the .cf-turnstile element or as the appearance option of turnstile.render(). Most visitors then see nothing, and the checkbox appears only when Cloudflare asks for it.
Note: the token is generated in the background and can take 1–2 seconds. If a visitor submits faster than that, the token is still empty. Wait for the widget's callback before submitting, or block the submission until turnstile.getResponse() returns a value.
Different field name
If your frontend sends the token under another name (e.g. captchaToken) or in a header, change the Turnstile Response parameter accordingly, e.g. {{ $json.body.captchaToken }} or {{ $json.headers["x-turnstile-token"] }}.
Testing
Cloudflare provides dummy keys that work on any hostname, including localhost.
Secret keys (use them in the n8n credential):
| Secret key | Result |
|---|---|
1x0000000000000000000000000000000AA |
Always passes |
2x0000000000000000000000000000000AA |
Always fails (invalid-input-response) |
3x0000000000000000000000000000000AA |
Fails with timeout-or-duplicate |
Site keys (use them in the frontend):
| Site key | Result |
|---|---|
1x00000000000000000000AA |
Visible, always passes |
2x00000000000000000000AB |
Visible, always blocks |
1x00000000000000000000BB |
Invisible, always passes |
2x00000000000000000000BB |
Invisible, always blocks |
3x00000000000000000000FF |
Forces an interactive challenge |
The dummy site keys produce the token XXXX.DUMMY.TOKEN.XXXX, which is only accepted by the dummy secret keys. Production secret keys reject it, and vice versa.
To test the node alone, pin data on the Webhook node with a body such as { "cf-turnstile-response": "XXXX.DUMMY.TOKEN.XXXX" } and use the "always passes" secret key.
Troubleshooting
All submissions go to Invalid with missing-input-response
The token doesn't reach n8n. Check the webhook input in the execution log:
- Is the widget inside the
<form>element? - Does your JavaScript include the
cf-turnstile-responsefield when it builds the request body? - Is the token sent under a different name? Then adjust Turnstile Response.
- With
interaction-only, was the form submitted before the token existed?
All submissions go to Invalid with invalid-input-response
The site key and secret key belong to different widgets, or dummy keys are mixed with production keys.
Submissions go to Invalid with timeout-or-duplicate
- The token is older than 300 seconds (the visitor waited long before submitting). The widget refreshes expired tokens automatically by default.
- The token was verified twice, e.g. because the form was submitted twice or the workflow verifies it in two places. Call
turnstile.reset()after every submission. For intentional retries, use Idempotency Key.
Everything goes to Invalid with hostname-mismatch
Check turnstile.hostname in the output and add that exact hostname to Expected Hostnames. Don't forget staging domains like *.webflow.io.
The node fails with "Cloudflare rejected the secret key"
The secret key in the credential is wrong or was rotated. Copy it again from the Cloudflare dashboard.
The node is not found after installation / "Unrecognized node type"
Restart n8n. In queue mode, install the package on the workers too.
CORS errors in the browser console
This is a Webhook node setting and unrelated to this node: set Options → Allowed Origins (CORS) on the Webhook node to your site's origin.
Security notes
- Verify on the server, always. A Turnstile widget on its own protects nothing. Only the server-side check rejects bots, and this node is that check.
- Keep the secret key secret. Only the site key belongs in the frontend.
- Tokens are single-use and expire after 300 seconds. Never cache them or verify them more than once without an idempotency key.
- Put the check first, directly after the Webhook node, before anything with side effects (emails, database writes, API calls).
- Use Expected Hostnames if the same widget could be embedded elsewhere.
- The visitor IP is only sent to Cloudflare if you configure Remote IP. Mention this in your privacy policy if required.
Compatibility
- Uses n8n nodes API version 1, developed against n8n 2.x
- Requires a self-hosted n8n instance or an n8n Cloud plan that allows community nodes
- No external npm dependencies
Development
npm install
npm run dev # starts a local n8n (Node.js ≥ 24) with this node and hot reload
npm run build
npm run lint
Project layout:
credentials/CloudflareTurnstileApi.credentials.ts # credential with secret key and credential test
nodes/CloudflareTurnstile/CloudflareTurnstile.node.ts # the node
nodes/CloudflareTurnstile/*.svg # icons (light/dark)
Resources
- Cloudflare Turnstile documentation
- Server-side validation (siteverify)
- Client-side rendering options
- Testing keys
- n8n community nodes documentation
Version history
1.0.0
- Initial release: Cloudflare Turnstile node with Valid/Invalid outputs, hostname and action checks, remote IP, idempotency key and configurable result field
- Cloudflare Turnstile API credential with credential test