TradingView Webhook 403: Cloudflare, Nginx Real IP and Four Verified Cases
TradingView Webhook 403: Fixing Cloudflare/Nginx Real IP and Reproducing Four Cases
A TradingView Webhook receiver I maintain once started returning 403 Forbidden. The most useful clue wasn’t the status code alone. It was the application’s response:
{"detail":"IP not allowed"}
First find out which layer is denying the request. If the application says the source IP is not allowlisted and the request travels through Cloudflare and Nginx, inspect whether $remote_addr still contains a Cloudflare edge address. Trust only verified Cloudflare proxy CIDRs with set_real_ip_from, then use real_ip_header CF-Connecting-IP to recover the originating address. Don’t start by changing tokens or restarting a database.
This comes from an operational incident I recorded in June 2026. Later, on an isolated VPS running Nginx 1.28.3 (Ubuntu), I executed four localhost synthetic requests and observed 403 / 401 / 202 / 403. The historical incident explains the real troubleshooting process; the isolated reproduction tests the proxy trust boundary. They are distinct forms of evidence.
Tell 403, 401 and 404 apart: who rejected the Webhook?
A 403 might come from Cloudflare WAF, Nginx, or the receiving application. Compare the response body against application and upstream logs before making changes.
In the incident’s receiver, the application checked the source-IP allowlist before the token. An unapproved IP returned 403 / IP not allowed; an approved source with a wrong token returned 401. For this specific receiver, a 401 was useful news: it meant the request had passed the IP gate and reached token validation. Don’t assume every application checks credentials in that order.
| What you observe | What to check first |
|---|---|
Application 403 / IP not allowed | Source IP forwarded by Nginx, trusted proxies, application allowlist |
Application 401 / Invalid token | Token format and verification, taking actual check order into account |
| 404 only on one path | Nginx location and proxy_pass, backend routing |
| Some alerts in a burst fail | Rate limits, stale listener processes, queues, timeouts |
One easy false positive: a historical request deliberately sent a forged header over the public Internet and got a 403 with server: cloudflare. That proves the edge blocked the request—not that Nginx rejected the forged header. The isolated experiment below tests that separately.
Cloudflare edge IPs are not TradingView sender IPs
The path is straightforward:
TradingView POST → Cloudflare → Nginx
→ source-IP allowlist → token check → receiver
Cloudflare connects to the origin over TCP, so Nginx initially sees a Cloudflare edge IP. Without trusted Real IP restoration, headers such as X-Real-IP may pass that edge IP to the backend. Comparing it to TradingView sender addresses then yields IP not allowed.
Cloudflare’s documentation explains that CF-Connecting-IP supplies the client address to the origin. But an HTTP header isn’t proof of identity. It is safe to trust the address only when the connection really comes from an approved proxy.
Two IP lists serve different purposes:
- TradingView Webhook sender IPs: These belong in the receiving application’s allowlist. The official TradingView support page currently lists four outbound IPv4 addresses for POST requests; recheck the page before relying on them.
- Cloudflare proxy CIDRs: These define who Nginx trusts under
set_real_ip_from. Compare them against the official Cloudflare IPv4/IPv6 list. They are not your business application’s sender allowlist.
TradingView outbound IPv4 (checked 2026-10-20)
52.89.214.238
34.212.75.30
54.218.53.128
52.32.178.7
Adding every Cloudflare edge CIDR to the application allowlist makes it far too permissive. Putting the four TradingView senders into set_real_ip_from confuses the real sender with the trusted intermediary.
The Nginx fix: establish trust, then restore the address
The June repair limited trusted proxy sources, enabled Nginx Real IP processing, and passed the normalized address to the receiver. This is a sanitized configuration pattern, not the original production config. Replace <...> placeholders with current official CIDRs and your local receiver endpoint; don’t paste it into production as-is.
# Insert each current Cloudflare IPv4 and IPv6 CIDR from the official lists
set_real_ip_from <CLOUDFLARE_IPV4_CIDR>;
set_real_ip_from <CLOUDFLARE_IPV6_CIDR>;
real_ip_header CF-Connecting-IP;
location /webhook {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP "";
proxy_pass http://127.0.0.1:<APP_PORT>;
}
Too many Cloudflare CIDRs to maintain by hand? Cloudflare publishes separate IPv4 and IPv6 lists. After reviewing them, put individual set_real_ip_from ...; directives in cloudflare-realip.conf and reference it with include /etc/nginx/conf.d/cloudflare-realip.conf;. That path is illustrative. Use either the included file or inline entries—not both.
An updater can automate this, but blindly overwriting the list and reloading Nginx is risky. At minimum verify the HTTPS source, IPv4/IPv6 CIDR syntax, and a nonempty result; keep the previous file, run nginx -t, and only then reload through an approved change process. This article does not claim to provide a tested one-click updater.
set_real_ip_from determines which peers may assert a client address; real_ip_header names the header to consult. Nginx rewrites $remote_addr only for trusted connections. With a single-address CF-Connecting-IP header, real_ip_recursive on is not generally needed; multi-proxy chains require their own trust analysis.
Dropping the original CF-Connecting-IP on the upstream hop prevents the application from reading an unnormalized external value. That still doesn’t stop someone bypassing Cloudflare and connecting directly to the origin. The Nginx Real IP module reference explains the trust directives.
During the historical repair, I kept a configuration backup, checked syntax, reloaded Nginx, and inspected health checks. These are the read-only checks:
nginx -V 2>&1 | grep -- '--with-http_realip_module'
nginx -t
nginx -t checks syntax and referenced files. Keep a rollback plan for any actual production change.
If the origin might be reachable directly from the Internet, you can test a non-sensitive health endpoint on a staging domain that you own, from a network outside Cloudflare. The hostname and IP below are documentation examples and must be replaced before use:
curl --resolve 'staging.example.com:443:203.0.113.10' \
--connect-timeout 3 --max-time 8 -sS -D - -o /dev/null \
'https://staging.example.com/health'
If the origin answers that health request directly with 200, inspect its firewall and protections such as Cloudflare Authenticated Origin Pulls. A single 403, however, doesn’t prove every path is protected. Do not send the probe to a live Webhook endpoint.
The June notes also contain three post-change checks: /health reported healthy; a simulated unauthorized source still got 403 / IP not allowed; and a simulated TradingView source with a deliberately invalid token got 401 / Invalid token. This supports the allowlist/token ordering, not an assertion that an actual TradingView alert was redelivered successfully—the incident notes contain no such receipt.
Just need your Webhook working again? Use this three-step path
During a real incident, you don’t need to build the three-port laboratory below first. Identify which component issued the rejection from the response body and logs; check the Nginx Real IP module, the trusted Cloudflare CIDRs, and the address handed to the app; after an approved configuration change, check health and authentication behavior, then confirm real delivery in TradingView’s alert log.
The following experiment is for readers who want to prove why a trusted proxy assertion differs from a spoofed header on an untrusted connection, or to turn that distinction into a regression test.
Advanced: four curl requests against trusted and untrusted listeners
A later isolated SSH experiment ran Nginx 1.28.3, a localhost network, and a synthetic Python receiver. The receiver checks the source IP first, then a test-only token. These results concern that synthetic setup.
| Synthetic input | Actual HTTP | Receiver body |
|---|---|---|
| Untrusted listener, missing client-IP header, valid test token | 403 | IP not allowed |
| Trusted listener, correct source header, invalid test token | 401 | Invalid token |
| Trusted listener, correct source and token | 202 | Accepted synthetic webhook |
Untrusted listener, forged CF-Connecting-IP, valid test token | 403 | IP not allowed |
To prove the fourth row, the Nginx listener must not trust the peer sending the forged header. If you trust 127.0.0.1 and spoof a header from 127.0.0.1 to that same listener, you’ve not simulated an untrusted connection.
The following local example is based on the verified fixture logic; I also checked it in an isolated non-root Nginx 1.26.3 container. It uses three available loopback ports: 18080 (receiver), 18081 (untrusted listener), and 18082 (trusted-proxy simulation). The receiver logs all three relevant forwarded headers.
Run the setup in a single Bash session:
LAB_PARENT="${TMPDIR:-/tmp}"
LAB_PARENT="${LAB_PARENT%/}"
[[ -n "$LAB_PARENT" ]] || LAB_PARENT="/tmp"
LAB="$(mktemp -d "${LAB_PARENT}/tv-webhook-lab.XXXXXXXX")"
cat > "$LAB/receiver.py" <<'PY'
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
source = self.headers.get("X-Real-IP", "")
forwarded = self.headers.get("X-Forwarded-For", "")
raw_cf = self.headers.get("CF-Connecting-IP")
print(f"HEADER_CHECK real={source} forwarded={forwarded} cf={'absent' if raw_cf is None else 'present'}", flush=True)
token = self.headers.get("X-Synthetic-Webhook-Token", "")
if forwarded != source or raw_cf is not None:
code, body = 500, b"Proxy header contract mismatch"
elif source != "198.51.100.77":
code, body = 403, b"IP not allowed"
elif self.path != "/webhook":
code, body = 404, b"Not Found"
elif token != "test-only-valid":
code, body = 401, b"Invalid token"
else:
code, body = 202, b"Accepted synthetic webhook"
self.send_response(code)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
HTTPServer(("127.0.0.1", 18080), Handler).serve_forever()
PY
cat > "$LAB/nginx.conf" <<NGINX
worker_processes 1;
pid $LAB/nginx.pid;
error_log $LAB/nginx-error.log notice;
events { worker_connections 64; }
http {
client_body_temp_path $LAB/body;
proxy_temp_path $LAB/proxy;
fastcgi_temp_path $LAB/fastcgi;
uwsgi_temp_path $LAB/uwsgi;
scgi_temp_path $LAB/scgi;
access_log off;
server {
listen 127.0.0.1:18081;
real_ip_header CF-Connecting-IP;
set_real_ip_from 192.0.2.0/24;
location /webhook {
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$remote_addr;
proxy_set_header CF-Connecting-IP "";
proxy_pass http://127.0.0.1:18080;
}
}
server {
listen 127.0.0.1:18082;
real_ip_header CF-Connecting-IP;
set_real_ip_from 127.0.0.1/32;
location /webhook {
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$remote_addr;
proxy_set_header CF-Connecting-IP "";
proxy_pass http://127.0.0.1:18080;
}
}
}
NGINX
python3 -u "$LAB/receiver.py" >"$LAB/receiver.log" 2>&1 &
APP_PID=$!
if nginx -t -p "$LAB/" -c "$LAB/nginx.conf"; then
nginx -p "$LAB/" -c "$LAB/nginx.conf" -g 'daemon off;' >"$LAB/nginx.log" 2>&1 &
NGINX_PID=$!
sleep 3
else
echo "Nginx config test failed: inspect configuration and clean up this lab" >&2
fi
198.51.100.77 and 192.0.2.0/24 are documentation-reserved test addresses, not real TradingView or Cloudflare allowlists. You’ll need Nginx with Real IP support, Python 3, curl, and three free ports.
Once the Nginx configuration check passes, run all four curl commands in the same Bash session:
# 1. Untrusted listener, no CF-Connecting-IP -> 403
curl --noproxy '*' -sS -i -X POST \
-H 'X-Synthetic-Webhook-Token: test-only-valid' \
http://127.0.0.1:18081/webhook
# 2. Trusted listener, incorrect test token -> 401
curl --noproxy '*' -sS -i -X POST \
-H 'CF-Connecting-IP: 198.51.100.77' \
-H 'X-Synthetic-Webhook-Token: test-only-wrong' \
http://127.0.0.1:18082/webhook
# 3. Trusted listener, correct test token -> 202
curl --noproxy '*' -sS -i -X POST \
-H 'CF-Connecting-IP: 198.51.100.77' \
-H 'X-Synthetic-Webhook-Token: test-only-valid' \
http://127.0.0.1:18082/webhook
# 4. Untrusted listener, spoofed header -> 403
curl --noproxy '*' -sS -i -X POST \
-H 'CF-Connecting-IP: 198.51.100.77' \
-H 'X-Synthetic-Webhook-Token: test-only-valid' \
http://127.0.0.1:18081/webhook
You can also inspect what headers the receiver actually saw:
grep '^HEADER_CHECK ' "$LAB/receiver.log"
Trusted requests should log real=198.51.100.77 forwarded=198.51.100.77 cf=absent; untrusted requests should log real=127.0.0.1 forwarded=127.0.0.1 cf=absent. This supplemental local check covers normalized X-Forwarded-For and removal of CF-Connecting-IP as well. The earlier VPS SSH four-case run mainly checked X-Real-IP and HTTP responses.
Run cleanup in that original Bash session:
if [[ -z "${LAB:-}" || -z "${LAB_PARENT:-}" || -z "${APP_PID:-}" || -z "${NGINX_PID:-}" ]]; then
echo 'Startup variables are missing: return to the original Bash session and inspect processes/files' >&2
else
kill "$NGINX_PID" "$APP_PID" 2>/dev/null || true
wait "$NGINX_PID" "$APP_PID" 2>/dev/null || true
if [[ "$LAB" == "$LAB_PARENT"/tv-webhook-lab.* && -d "$LAB" && ! -L "$LAB" ]]; then
rm -rf -- "$LAB"
else
echo 'LAB path does not match this run: refusing deletion; inspect manually' >&2
fi
fi
LAB_PARENT uses TMPDIR, falling back to /tmp; it doesn’t assume macOS creates temporary directories in /tmp. I tested cleanup with a custom TMPDIR path, but did not rerun Nginx on macOS.
After fixing Real IP, check real alert delivery
The historical incident notes record the Real IP correction, authentication-branch checks, and service health. They also mention two old service units competing for the same listening port—another operational problem, not the cause of the 403. An earlier, separate 404 incident was a reverse-proxy routing mistake.
My troubleshooting order is: 403—identify the rejecting layer and source IP; 401—check tokens in the context of your app’s auth order; 404—check routing; intermittent bursts—inspect limits, listeners and timeouts. After repairs, use TradingView’s alert log to confirm actual delivery.
The official TradingView Webhook documentation describes POST delivery, ports 80/443 only, and a three-second receiver timeout. Check those constraints against your ingress design too.
Find out whose address the application actually sees before deciding whether to allow it. That is more useful—and safer—than relaxing the allowlist blindly.
For a related but separate question—whether an unhealthy endpoint triggers notifications and a later recovery clears them—see my Gatus + Docker Compose HTTP 503 alert/recovery test. That experiment covers health monitoring, not this Real IP failure.