cloudflare-turnstile

n8n node to verify Cloudflare Turnstile tokens from form submissions

Package Information

Released: this month
Downloads: 0 weekly / 0 monthly
Latest Version: 1.0.0
Author: Niklas Forst

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

  • 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 action values
  • 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)

  1. Go to Settings → Community Nodes.
  2. Click Install.
  3. Enter n8n-nodes-cloudflare-turnstile and 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

  1. Open the Cloudflare dashboard → Turnstile → Add widget.

  2. Enter a name and add all hostnames the form runs on, e.g. example.com, www.example.com and example.webflow.io.

  3. 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.
  4. 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 an error field instead.
  • Network or API error: The node fails, or with Continue On Fail the item goes to Invalid with an error field.
  • 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-response field 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

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

License

MIT

Discussion