403s on websocket upgrade requests

Subject: WebSocket upgrade requests rejected with 403 at the edge; identical HTTP requests succeed

App: crot-pro-api-staging (region iad, 2 machines)

Symptom: Every WebSocket upgrade request to wss://crot-pro-api-staging.fly.dev/ws/live is rejected with HTTP 403 Forbidden. The response is generated by Fly’s own edge (server: Fly/e93089d61 (2026-08-20), via: 1.1 ``fly.io``, 1.1 ``fly.io) — our application (uvicorn) never receives the request; application-level logging confirms this. A plain HTTP request to the same app through the same edge path succeeds normally.

Reproduction:

curl -v -N \
  -H "Connection: Upgrade" \
  -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" \
  -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  "https://crot-pro-api-staging.fly.dev/ws/live?token=invalid-probe"

Response:

HTTP/1.1 403 Forbidden
content-type: text/plain
connection: close
server: Fly/e93089d61 (2026-08-20)
via: 1.1 fly.io, 1.1 fly.io
fly-request-id: 01M0Y0KARW63J52T53SYEHJYZ5-gru
content-length: 0

Compare with a plain GET to the same app:

curl -sI https://crot-pro-api-staging.fly.dev/api/v1/health

→ succeeds normally, same edge/region path (fly-request-id also -gru suffixed).

Already ruled out on our side: scaled to a single machine (still fails), tested both our ticket-based and legacy JWT-based auth query params (both fail identically before reaching our route handler at all — confirmed via application logging), tested from three different clients (Electron’s ws library, plain Node.js, and a browser’s native WebSocket sending a proper Origin header) — all get the identical 403. No application middleware in our stack can intercept WebSocket-scope ASGI requests (verified against Starlette’s BaseHTTPMiddleware semantics), and no security/restriction banner is visible on the app’s dashboard page.

Hey @k4li5urf3r

It looks like the 403 response is generated by your app, not the proxy:

2026-08-26 07:32:46.050565000 backhaul <- backend: Response { status: 403, version: HTTP/1.1, headers: { "date": "<redacted>", "content-length": "0", "content-type": "text/plain", "connection": "close", }, body: Body( Body( Empty, ), ), }

I also see it gets logged by the app like this:

"WebSocket /ws/live?token=invalid-probe" 403