
Cloudflare Access Blocks Your n8n Webhooks. Here Is the Fix That Keeps Both.
Cloudflare Access is an identity proxy. It expects every request to carry a human session, and a webhook sender has no identity to offer, so Access answers with a login redirect or a 403 and the delivery fails before n8n ever sees it. For machines you control, issue an Access service token and add a policy whose action is Service Auth. For senders you don't control, scope a Bypass policy to the webhook path onlyand let n8n authenticate the caller itself. Here's each one, plus the four gotchas that cost me real hours.
Why Access 403s a Webhook It Was Never Supposed to Touch
I run roughly a dozen self-hosted services through a single Cloudflare Tunnel on a Mac mini — n8n, a SEO auditor, a calendar, a vault, an analytics box, a GPU render node on another machine. Putting Cloudflare Access in front of all of them took about ten minutes and felt like the most responsible thing I'd done all quarter. Then, over the following week, things started failing in ways that made no sense: a workflow that had run for months stopped firing, a monitoring script started reporting the service as down while the service was demonstrably up, and an API client returned a wall of HTML instead of JSON.
Every one of those was the same thing. Access doesn't protect the app — it stands in front of it and interrogates the caller.A browser passes because it carries a session cookie issued after you logged in with Google or a one-time PIN. A webhook from Stripe carries no cookie, has no browser to redirect, and cannot complete an OAuth handshake. Access does the only thing it can and refuses the request. The sender logs a failed delivery, and you go debugging inside n8n, which is the one place the problem definitely isn't.
The mental model:
Access answers the question “is this a person I recognize?” Your webhook's answer is “I'm not a person.” That is a legitimate answer — you just have to give Access a way to accept it.
Three Ways to Fix It, and How to Pick
Which fix is right depends entirely on one question: can the caller set custom HTTP headers? That single fact decides everything else.
- Service token — for callers you own: your own scripts, a second n8n instance, a monitoring probe, an AI agent. Full Zero Trust protection stays on, and the caller proves itself with two headers. This is the right default.
- Bypass policy on the webhook path — for third parties that only let you paste a URL: Stripe, Twilio, Calendly, a form builder. Access steps aside for that path and n8n does its own authentication.
- Split hostnames — the structural version of option two. The editor lives on one hostname behind Access; production webhooks live on another with no Access application at all. More setup, far fewer surprises later.
What you should not do is the thing everyone tries first, which is deleting the Access application to make the error go away. That exposes the n8n editor and its REST API to the internet, and an n8n instance is a remote code execution engine with your credentials already loaded in it. Keep the door locked and cut a proper key.
Fix #1: A Service Token for Anything You Control
A service token is a Client ID and Client Secret pair that Cloudflare validates at the edge in place of a login. In Zero Trust, go to Access service authentication, create a token, and copy the secret right then — it is displayed exactly once. You also choose the duration; mine are long-lived because the alternative is an outage every 30 days, but write the expiry date somewhere you will actually see it.
Then comes the step that eats an afternoon if you miss it. Open the Access application for that hostname and add a policy whose action is Service Auth, with an include rule selecting your token. Cloudflare's own docs are blunt about this: set the action to Service Auth, or Access will still prompt for an identity provider login. An Allow policy that includes a service token looks completely correct in the dashboard and does nothing.
Now the caller sends two headers on every request:
curl -s -o /dev/null -w "%{http_code}\n" \
-H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
-H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \
-H "Authorization: Bearer $N8N_API_KEY" \
https://n8n.example.com/api/v1/workflowsNote the third header. Clearing Access is not the same as logging into the app. Access decides whether the request reaches your origin; the origin still wants its own API key. I lost a solid twenty minutes to a 401 that I was certain was a token problem, when in fact the service token was working perfectly and I had simply forgotten the app credential underneath it.
If the caller is a SaaS product that lets you set exactly one custom header — a surprisingly common limitation — Cloudflare supports packing both values as a JSON object into a single header, but you have to enable that on the application first. Reach for it only when you genuinely can't send two headers; the standard pair is better supported everywhere else. The same shape of problem shows up when you expose an MCP server to remote clients, which I covered in remote MCP server authentication.
Fix #2: A Bypass Policy Scoped to the Webhook Path
Stripe will not send you a CF-Access-Client-Id header. Neither will Twilio, Calendly, or most form builders. For those, you carve out the path.
Access applications match on hostname plus path, so you can define an application covering n8n.example.com/webhook/ and give it a single Bypass policy for everyone, while the application covering the bare hostname keeps its strict identity policy. Incoming webhooks sail through; the editor at the root still demands a login.
Bypass means no authentication at all.
Cloudflare is no longer checking anything on that path, so the endpoint is exactly as safe as n8n makes it. Give it real protection: add a Header Auth credentialon the Webhook node, or verify the sender's HMAC signature in the first node of the workflow. A bypassed path with an anonymous webhook on it is an open POST endpoint that runs your workflow for anyone who finds the URL.
Two details people get wrong here. First, n8n serves test and production webhooks on different prefixes — /webhook-test/ and /webhook/ — so a bypass on one does nothing for the other, which produces the maddening result of a webhook that works in the editor and 403s the moment you activate the workflow. Second, order matters: keep the bypass as narrow as the path you actually need, never on the hostname root.
Fix #3: Split the Hostnames (What I Actually Run)
After the third time I debugged a path-scoped policy, I stopped being clever and split the hostname instead. One tunnel, two public hostnames pointing at the same container:
# cloudflared config — one tunnel, two hostnames
ingress:
- hostname: n8n.example.com # editor -> Access application, strict policy
service: http://127.0.0.1:5678
- hostname: hooks.example.com # webhooks -> no Access application
service: http://127.0.0.1:5678
- service: http_status:404
# n8n env so generated URLs point at the open hostname
WEBHOOK_URL=https://hooks.example.com/That WEBHOOK_URL line is the part people skip. Without it, n8n keeps printing production webhook URLs on the protected hostname, you paste one into Stripe, and it 403s — even though you built the open hostname specifically to avoid that. The URL n8n shows you has to be the URL that is actually reachable.
The payoff is that the security boundary is now a hostname rather than a path prefix buried in a policy list. Anything on the editor hostname is protected by definition; anything on the hooks hostname is public by definition and is expected to authenticate its own callers. Nobody, including future me at 1am, has to remember which prefix was carved out. If you're running n8n at any real volume, pair this with the setup in n8n queue mode scaling, where webhook processes are separate from workers anyway.
Four Gotchas That Cost Me Real Hours
1. Not every 403 is Access. This one cost me most of an evening. A render node behind the same tunnel kept returning 403 to my script while the browser loaded it fine, and I rebuilt the service token twice before checking the response body. It was Cloudflare's bot protection, not Access — the request had no User-Agent, which is a classic automated-traffic signal. Adding a normal browser User-Agent fixed it instantly. Always read the body: an Access rejection returns a login page or an Access-branded error, while a WAF or bot block returns the Cloudflare challenge page with a Ray ID.
2. The most specific application wins. If you have a wildcard Access application on *.example.com and a specific one on the n8n hostname, only one evaluates. Adding a service token to the wrong one produces the special kind of bug where your change is correct and has no effect.
3. Expired tokens look exactly like broken workflows.A service token that hits its duration doesn't announce itself — the calls just start failing, months after you last thought about Cloudflare. If a machine-to-machine integration that ran fine for a year breaks with no deploy behind it, check the token expiry before you check anything else.
4. Health checks need the token too. Every uptime monitor I pointed at these hostnames reported them as down the moment Access went up. Either give the monitor the service token headers or point it at a path with a bypass, otherwise you have built an alerting system that pages you about your own security working correctly.
The 30-Second Diagnostic
Before touching a workflow, find out which layer is refusing you. Run the request bare, then with the token, and compare:
URL=https://n8n.example.com/healthz # 1. bare request - what does the edge say? curl -sD - -o /dev/null "$URL" | head -20 # 2. same request with the service token curl -sD - -o /dev/null \ -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \ -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" "$URL" | head -20
- 302 to a cloudflareaccess.com login URL: Access is working and your caller has no identity. Service token or bypass.
- 403 with a Cloudflare challenge page and a Ray ID: this is the WAF or bot protection, not Access. Check your User-Agent and any custom rules.
- 403 even with both headers: the policy action is almost certainly Allow instead of Service Auth, or the token is on a different application.
- 200 at the edge but 401 from the app: Access passed you through. Add the application's own API key.
Two curls and you know whether the problem lives at Cloudflare or inside n8n. That distinction is the whole game — almost all the time I've lost to this was spent debugging on the wrong side of the edge. It's the same lesson as the Docker bind mount corruption hunt: verify which layer is actually lying to you before you start fixing things.
The Rules I Follow Now
Cloudflare Tunnel plus Access is still the best way I know to self-host without opening a single port on your router, and n8n retired its own tunnel service specifically so people would use something like this. None of the friction above is a reason to go back to port forwarding. It's a reason to set it up deliberately.
- Editor and API behind Access with a strict identity policy — always, no exceptions.
- One service token per calling system, never one shared token for everything, so you can revoke a single client without an outage everywhere else.
- Third-party webhooks on a separate hostname with no Access, authenticated by n8n header auth or an HMAC check.
- WEBHOOK_URL set to the reachable hostname, so the URL n8n prints is the URL that works.
- Token expiry dates on a calendar, because silent expiry is the failure mode you will not diagnose quickly.
The first hour of this is annoying. Everything after it is a system where the dashboard is genuinely locked, the webhooks genuinely fire, and you can tell the two apart from a terminal in half a minute. For the deeper reference, Cloudflare's service tokens documentation is short and worth reading end to end, and n8n's note on retiring its tunnel service explains why a real tunnel is now the expected setup.
Frequently Asked Questions
Why does Cloudflare Access return 403 on my n8n webhook?
Cloudflare Access is an identity proxy that sits in front of your origin and expects every request to carry a valid session cookie or a service token. A webhook sender has no browser, no cookie, and no identity provider login, so Access refuses the request before n8n ever sees it. In a browser you get redirected to a login screen; a machine gets a 403 or an HTML login page it cannot parse, and the sender records a failed delivery. The webhook is not broken, it is being rejected one layer above the application.
How do I let a service call an app behind Cloudflare Access?
Create a service token in Cloudflare Zero Trust under Access service authentication, then add a policy to the application whose action is Service Auth and whose include rule selects that token. The action matters: if you leave it on Allow, Access still prompts for an identity provider login and the token is ignored. Your client then sends the token as two headers, CF-Access-Client-Id and CF-Access-Client-Secret, on every request.
Can I protect the n8n dashboard but leave webhooks open?
Yes, and it is the setup worth aiming for. Access applications match on hostname plus path, so you can put one application on the editor hostname with a strict identity policy and either add a Bypass policy scoped to the /webhook/ path or move production webhooks to a separate hostname with no Access application at all. Set n8n's WEBHOOK_URL environment variable to that public hostname so the URLs n8n hands out actually match the route that is reachable.
Is a Cloudflare Access bypass policy safe for webhooks?
A Bypass policy removes authentication entirely for the paths it covers, so the endpoint is only as safe as the checks the application itself performs. That is acceptable for a webhook if you give the path its own secret: use n8n's header auth credential on the Webhook node, verify the sender's HMAC signature, and keep the path unguessable. It is not acceptable to bypass the whole hostname, because that exposes the editor and the REST API along with it.
Does passing Cloudflare Access log me into the application?
No. Access decides whether the request reaches your origin at all; it does not authenticate you to the application behind it. Requests that clear Access still need whatever credential the app expects, which usually means sending an API key or bearer token alongside the two service token headers. A request that carries only the Access headers will get past Cloudflare and then be rejected by the app with a 401.
Want this wired up properly the first time?
I build and run self-hosted automation stacks — n8n, tunnels, Zero Trust, AI agents — for agencies and operators who need the thing to work unattended. If your webhooks are fighting your security layer, or you want the whole stack designed so they never do, let's talk.
Related Posts
n8n
Scaling Self-Hosted n8n: When to Switch to Queue Mode (2026)
Default n8n runs the editor, webhooks, and every execution in one Node process — it works until the UI crawls during runs and webhooks drop under load. The signal to move is the main process pinned near 80% CPU; the fix is queue mode: a main instance, a Redis broker, and dedicated workers on Postgres. The exact signals I watch, the env vars I set, and the mistakes that cost me a night of dropped executions.
n8n
n8n MCP Server vs MCP Server Trigger: Which One You Actually Need
n8n ships two features named some version of "MCP server" and they do opposite jobs. The built-in server is instance-level — one connection lets an AI client list, build, update and run workflows across your whole n8n, and since April 29 2026 it can author workflows from scratch. The MCP Server Trigger node is workflow-level, exposing only the tools you attach. Pick by blast radius, plus the queue-mode routing gotcha that silently breaks SSE.
n8n
n8n AI Agent Tool vs Sub-Workflow: When to Use Each
n8n gives you two ways for one agent to delegate to another. The AI Agent Tool node keeps the whole hierarchy on one canvas inside a single execution. The Call n8n Sub-Workflow tool hands the job to a separate workflow with its own execution record — one you can retry, replay, reuse from other workflows and test on its own. That execution boundary is the entire decision, not how complex the build is. Plus the sub-node trap that silently makes both process only the first item.