
ComfyUI 403 Forbidden Behind a Reverse Proxy: The Fix
A ComfyUI 403 behind a reverse proxy comes from one of three layers, and the response body tells you which. If it says error code: 1010, Cloudflare blocked your client before ComfyUI ever saw it. If the body is empty, ComfyUI rejected it itself: either your proxy rewrote the Host header to localhost, or a page on another domain called ComfyUI from the browser. Forward the Host header, send a real User-Agent, and call ComfyUI from your backend, not the browser.
Which Layer Sent Your 403?
Most threads about this error assume there's one cause. There are three, and they need different fixes. Here's the map I wish I'd had:
| Who said no | 403 body | Trigger | Fix |
|---|---|---|---|
| Cloudflare edge | error code: 1010 | Client signature, e.g. Python urllib's default User-Agent | Real User-Agent, or an Access service token |
| ComfyUI Host check | empty | Proxy sends Host: 127.0.0.1, browser sends a public Origin | Forward the original Host header |
| ComfyUI cross-site check | empty | Sec-Fetch-Site: cross-site from a page on another domain | Backend proxy, or --enable-cors-header |
One gotcha: if you're going through a Cloudflare Tunnel, both kinds of 403 come back with server: cloudflare and a cf-rayheader, because ComfyUI's response still travels through Cloudflare. Don't diagnose from headers. Run curl -si and read the body.
What I Actually Tested
My ComfyUI runs on a Windows GPU box at home that I call La Bestia (the same machine from my ComfyUI best practices post). It's on ComfyUI 0.33.0, launched with --listen 0.0.0.0, and reachable two ways: a Cloudflare Tunnel on a public hostname, and directly over Tailscale. On 8 October 2026 I fired requests at both from my Mac mini, changing one header at a time.
The trick that made this safe to test against a live box: POST a graph with a node type that doesn't exist. If the request survives every gate, ComfyUI answers 400 missing_node_typeand queues nothing. So a 400 means “got all the way in” and a 403 means “stopped at the door”, and no GPU time gets wasted either way.
- Tunnel, curl, default User-Agent: 200
- Tunnel, Python
urllib, default User-Agent: 403, bodyerror code: 1010 - Tunnel, Python
requests: 200 - Tunnel,
urllibwith any custom User-Agent: 400 (reached ComfyUI) - Tunnel,
Sec-Fetch-Site: cross-site: 403, empty body, even on a plain GET - Direct,
Host: 127.0.0.1:8188+ publicOrigin: 403 on POST and GET - Direct, Host forwarded + matching Origin: 400 (passes)
- Direct,
Host: localhost:8188+Origin: http://localhost:3000: 403 - WebSocket
/wshandshake, Host 127.0.0.1: 403. Same handshake with the Host forwarded: 101 Switching Protocols
That last one matters more than it looks. If your UI loads but never shows progress, or the queue button seems to do nothing, check the WebSocket. It goes through the same middleware as every other route.
Why Does ComfyUI Return 403 When the Host Is localhost?
It's a deliberate guard. A comment in ComfyUI's server.py explains it: without it, any random website you visit could POST a workflow to 127.0.0.1:8188 and your browser would happily send it. So the origin_only_middleware compares the Host header to the Originheader, and if the Host resolves to a loopback address and the two don't match, it returns an empty 403.
Now look at what a default nginx config sends upstream. With proxy_pass http://127.0.0.1:8188; and nothing else, nginx sets Host to the upstream address, 127.0.0.1:8188. Your browser, meanwhile, sends Origin: https://comfy.yourdomain.com. Loopback host, mismatched origin: 403. The middleware is doing exactly its job; the proxy made a legit request look like the attack it's built to stop.
The code checks every method. In real life, though, the UI usually loads and only Queue fails, because browsers don't attach an Originto a normal same-origin page load but always attach one to a POST. That's why people end up blaming ComfyUI-Manager's security level, as in this ComfyUI-Manager issue, when it's a header problem.
The nginx fix, with the WebSocket headers you'll need anyway:
location / {
proxy_pass http://127.0.0.1:8188;
proxy_set_header Host $host; # the actual fix
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # /ws progress events
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s; # long renders
client_max_body_size 100m; # image uploads
}Caddy and Traefik pass the original Host through by default, so they rarely hit this. Cloudflare Tunnel does too, unless you've set httpHostHeader: localhostin the ingress rule's origin parameters. Some guides recommend that for other apps. On a ComfyUI route it recreates the nginx bug exactly. Rule: whatever sits in front of ComfyUI must pass the public hostname through as Host.
Why Does My Web App Get 403 Calling ComfyUI?
Different check, same empty body. The first thing the middleware does is look at Sec-Fetch-Site. If it's cross-site, the answer is 403, no Host comparison, no exceptions. Browsers set that header themselves whenever a page on app.yourdomain.com fetches from comfy.otherdomain.com, so a Next.js frontend calling ComfyUI straight from the client gets blocked even when the proxy is perfect. I proved it with a plain GET to /system_stats: 200 without the header, 403 with it.
You have two ways out:
- Call ComfyUI from your server. A route handler in your app talks to ComfyUI; the browser only talks to your app. No
Sec-Fetch-Site, no CORS, and the ComfyUI URL never ships to the client. This is what I do for every client pipeline. - Start ComfyUI with
--enable-cors-header https://app.yourdomain.com. This doesn't loosen the origin check. It replaces it with a CORS middleware that addsAccess-Control-Allow-*headers. Pass the flag with no value and the allowed origin becomes*, which means any site can drive your GPU from a visitor's browser. Always pass an exact origin.
My take: option 1, every time, unless you're building a pure static frontend with no server at all. Option 2 turns your ComfyUI into a public API whose only gate is a header browsers enforce and scripts ignore.
Why Does My Python Script Get Cloudflare Error 1010?
This one never reaches ComfyUI. Cloudflare's error 1010 means the zone banned the client based on its browser signature, and Python's urllib announces itself as Python-urllib/3.x. On my zone, that was enough: urllib got 1010 while curl and requestswent straight through. Your zone's settings may flag different clients, so don't assume requests is always safe; test the exact client your job uses.
import json, urllib.request
req = urllib.request.Request(
"https://comfy.yourdomain.com/prompt",
data=json.dumps({"prompt": graph}).encode(),
headers={
"Content-Type": "application/json",
"User-Agent": "my-render-worker/1.0", # anything but the default
},
)
print(urllib.request.urlopen(req, timeout=60).read())Setting a User-Agent is the quick fix. The real fix for a machine-to-machine API is to put Cloudflare Access with service tokens in front, the same way I lock down n8n webhooks. Then your scripts authenticate with a token instead of trying to look like a browser.
And check what you got back. A 1010 is 17 bytes of plain text. A script that downloads from /view without checking status or size will save that as hero.png and report success. I covered the defensive download pattern in ComfyUI API /history empty.
Is the Origin Check Enough to Expose ComfyUI Publicly?
No, and my test showed why. A POST to /prompt through the tunnel with Origin: https://evil.example and no Sec-Fetch-Siteheader went straight to the executor. The Host check only applies when Host is loopback, which it isn't on a public hostname, and the cross-site check only fires on a header that real browsers add and scripts don't.
So the middleware protects you from malicious web pages. It does nothing against someone who finds your hostname and runs curl. ComfyUI has no login, and queued jobs run with full access to your custom nodes and filesystem. If ComfyUI is reachable from the internet, put an authenticating proxy in front of it. Cloudflare Access is what I use; Tailscale-only is even simpler if nobody outside your tailnet needs it. The same thinking applies to anything agent-facing, which is why I wrote up remote MCP server authentication separately.
The 5-Minute Diagnosis
- Reproduce with
curl -siand read the body.error code: 1010means Cloudflare. Empty means ComfyUI. - For Cloudflare: set a User-Agent on the client, then plan an Access service token.
- For an empty 403 from the browser UI: check what Host your proxy sends. Anything resolving to
localhostor127.0.0.1is the bug. Forward$host. - For an empty 403 from your own app: it's
Sec-Fetch-Site: cross-site. Move the call server-side. - Retest with a fake node type. A
400 missing_node_typeproves you're through every gate without burning a render.
ComfyUI also logs request with non matching host and origin ... returning 403for the Host case, so if you can see the console, it'll name the exact pair. The cross-site rejection logs nothing, which is why it's the one people chase longest.
Want ComfyUI Exposed Safely to Your App?
I set up self-hosted ComfyUI behind tunnels and auth, with a backend that queues jobs, verifies the files and survives GPU restarts. Tell me what you're generating and where it needs to land.
All status codes above were observed 8 October 2026 against ComfyUI 0.33.0 (Windows, launched with --listen 0.0.0.0) through a Cloudflare Tunnel and directly over Tailscale. Middleware behavior (Sec-Fetch-Site check, loopback-only Host/Origin comparison, --enable-cors-header replacing it, default *) verified the same day against server.py and comfy/cli_args.py on ComfyUI master.
Related Posts
AI Models
ComfyUI API /history Empty? Why and the Fix
ComfyUI only writes a history entry when a job finishes, so an empty /history/{prompt_id} can mean still running, never accepted, wiped by a restart, or evicted. Checking /queue tells them apart; status_str and the actual file tell you if it worked.
AI Models
ComfyUI Best Practices: My Production Image Pipeline on an RTX 5090
Hard-won ComfyUI best practices from a production SDXL pipeline — native resolution vs upscaling, FaceDetailer for tack-sharp eyes, LoRA OOM fixes, and reusable workflow architecture.
AI Models
Claude Sonnet 4.5 Deprecation: Migrate to Sonnet 5.5
claude-sonnet-4-5-20250929 retires on the Claude API on 30 November 2026. I sent the same requests to Sonnet 4.5 and Sonnet 5.5: seven came back 400, the tokenizer counted 24% more, and the default config cost 29% more until I set effort.