# TradingView Webhook 403: Cloudflare, Nginx Real IP and Four Verified Cases

> Canonical: https://vxi.me/en/posts/tradingview-webhook-403-nginx-realip/
> Locale: en-us
> Published: 2026-10-20
> Updated: 2026-10-20
> Markdown: https://vxi.me/en/posts/tradingview-webhook-403-nginx-realip.md

# 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:

```json
{"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:

```text
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](https://developers.cloudflare.com/support/troubleshooting/restoring-visitor-ips/restoring-original-visitor-ips/) 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](https://www.tradingview.com/support/solutions/43000529348-how-to-configure-webhook-alerts/) 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](https://www.cloudflare.com/ips/). They are **not** your business application's sender allowlist.

```text
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.

```nginx
# 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](https://www.cloudflare.com/ips-v4) and [IPv6](https://www.cloudflare.com/ips-v6) 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](https://nginx.org/en/docs/http/ngx_http_realip_module.html) 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:

```bash
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:

```bash
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](https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/explanation/). 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**:

```bash
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**:

```bash
# 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:

```bash
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**:

```bash
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](https://www.tradingview.com/support/solutions/43000529348-how-to-configure-webhook-alerts/) 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](/posts/gatus-docker-compose-http-503-alert-recovery/). That experiment covers health monitoring, not this Real IP failure.
