Summary
When cloudflared is left on its default --protocol=auto (which prefers QUIC), incoming WebSocket upgrades arrive at the origin without their Upgrade: websocket header. The origin sees a plain GET and responds 400. Browsers surface this as code: 1006, wasClean: false, no open event ever fires. Setting --protocol=http2 reliably restores the upgrade end-to-end. HTTP (non-upgrade) traffic through the same tunnel is unaffected.
Environment
cloudflared running inside the official Home Assistant add-on homeassistant-apps/app-cloudflared, no-autoupdate: true (so the binary is whatever shipped with that add-on version).
- The cloudflared connection advertises these features in
GET /accounts/{a}/cfd_tunnel/{t}/connections:
allow_remote_config, serialized_headers, support_datagram_v2, support_quic_eof, management_logs.
- Origin is Home Assistant (
aiohttp WebSocketResponse) at http://homeassistant:8123.
- Cloudflare Access in front of the hostname; same Access policy across the broken WS path and the working HTTP paths.
Symptom
Browser opens wss://example.com/api/websocket. Outcome:
- WebSocket closes immediately with
code: 1006, wasClean: false, ~260 ms, no open event.
- Chrome DevTools logs the request as a plain
GET /api/websocket returning HTTP 400, Content-Type: text/plain, body:
No WebSocket UPGRADE hdr: None
Can "Upgrade" only to "WebSocket".
That body is generated by aiohttp.WebSocketResponse.prepare() exactly when the Upgrade request header is missing or not websocket. So by the time the request reaches the origin via cloudflared, the Upgrade: websocket header is gone.
All HTTP traffic through the same hostname works normally - /, /auth/providers, /api/, the recovery-script's cache-busting /?_cb=... probe - Cloudflare Access cookie auth validates and the origin returns the expected statuses. So the failure is isolated to the WebSocket upgrade path.
Workaround
Setting --protocol=http2 on cloudflared (in the HA add-on: run_parameters: ["--protocol=http2"]) restores the upgrade. With HTTP/2 transport to the edge, the Upgrade header survives end-to-end and the origin issues the expected 101 Switching Protocols. Verified live; the symptom resolved within the time it took the add-on to restart.
Zone-level HTTP/3 (browser <-> edge) stays on and is unaffected by this change - only the cloudflared <-> edge link drops from QUIC to HTTP/2.
Why this matters
HA + Cloudflare Tunnel + Cloudflare Access is one of the most commonly recommended self-hosted configurations. When this fires, the failure is silent on the user side - the Service-Worker-cached frontend keeps loading, automations keep running on the server, but every dashboard ends up displaying "Unable to connect to Home Assistant" forever, and users have no obvious path from that symptom to "drop QUIC".
Ask
Either fix the QUIC <-> HTTP/1.1 translation so Upgrade: websocket propagates correctly, or document explicitly that --protocol=http2 is required for WebSocket origins. The README for the HA cloudflared add-on does not currently mention this.
Update — 2026-05-29: symptom recurred despite --protocol=http2
The workaround documented above is unreliable. Today (2026-05-29 UTC) the exact same symptom returned without any user-side change:
- cloudflared add-on confirmed running
--protocol=http2. Add-on log line at startup: INF Settings: map[... p:http2 protocol:http2] followed by INF Initial protocol http2, and every Registered tunnel connection line shows protocol=http2 (4 of them, to fra03/hel01/hel02). No quic anywhere in the cloudflared log.
- Browser-side symptom identical to the original:
wss://.../api/websocket closes with code: 1006, wasClean: false, ~278 ms, no open event. Plain GET to /api/websocket returns the same HA aiohttp 400 with body No WebSocket UPGRADE hdr: None.
- Browser cf-ray was on the
LAX edge POP both during the broken state and after self-recovery, so the variable isn't the geographic edge.
- No user action between broken → working. The symptom self-resolved within hours.
This means --protocol=http2 is not a reliable workaround — the bug surfaces on HTTP/2 transport too. The QUIC path may amplify it, but the failure isn't purely in the QUIC ↔ HTTP/1.1 translation. Something upstream of (or at) cloudflared on the HTTP/2 path is also affected.
Correlation worth flagging
Both occurrences I've seen sit immediately after a Cloudflare managed DDoS L7 ruleset push:
- First occurrence (~May 22-23): DDoS L7 ruleset pushed from
3299 → 3300 on 2026-05-22T11:49:51Z.
- This occurrence (2026-05-29): DDoS L7 ruleset pushed from
3300 → 3301 on 2026-05-28T18:35:29Z (~10 hours before the symptom became visible).
Two correlated events isn't proof, but the timing has the shape of a managed-ruleset regression that gets rolled back — both times the symptom resolved without user action, and nothing changed on my side in between. If anyone on the cloudflared side can correlate with internal DDoS L7 rule changes around those timestamps, that would close the loop.
Updated ask
- Investigate whether the HTTP/2 transport path can also lose the
Upgrade: websocket header (transiently or otherwise), not just QUIC.
- Coordinate with whoever owns DDoS L7 managed rules on whether the May 22 and May 28 pushes touched anything that could affect WS upgrade routing through tunneled origins.
Related reading
A write-up of the symptom and the same fix, plus a browser-side detection probe for the upgrade-stripped signature, lives at: https://github.com/mayerwin/HA-Cloudflare-Access-Recovery#second-failure-mode-websocket-upgrade-stripping
Summary
When
cloudflaredis left on its default--protocol=auto(which prefers QUIC), incoming WebSocket upgrades arrive at the origin without theirUpgrade: websocketheader. The origin sees a plainGETand responds 400. Browsers surface this ascode: 1006, wasClean: false, noopenevent ever fires. Setting--protocol=http2reliably restores the upgrade end-to-end. HTTP (non-upgrade) traffic through the same tunnel is unaffected.Environment
cloudflaredrunning inside the official Home Assistant add-onhomeassistant-apps/app-cloudflared,no-autoupdate: true(so the binary is whatever shipped with that add-on version).GET /accounts/{a}/cfd_tunnel/{t}/connections:allow_remote_config,serialized_headers,support_datagram_v2,support_quic_eof,management_logs.aiohttpWebSocketResponse) athttp://homeassistant:8123.Symptom
Browser opens
wss://example.com/api/websocket. Outcome:code: 1006,wasClean: false, ~260 ms, noopenevent.GET /api/websocketreturningHTTP 400,Content-Type: text/plain, body:That body is generated by
aiohttp.WebSocketResponse.prepare()exactly when theUpgraderequest header is missing or notwebsocket. So by the time the request reaches the origin via cloudflared, theUpgrade: websocketheader is gone.All HTTP traffic through the same hostname works normally -
/,/auth/providers,/api/, the recovery-script's cache-busting/?_cb=...probe - Cloudflare Access cookie auth validates and the origin returns the expected statuses. So the failure is isolated to the WebSocket upgrade path.Workaround
Setting
--protocol=http2on cloudflared (in the HA add-on:run_parameters: ["--protocol=http2"]) restores the upgrade. With HTTP/2 transport to the edge, theUpgradeheader survives end-to-end and the origin issues the expected101 Switching Protocols. Verified live; the symptom resolved within the time it took the add-on to restart.Zone-level HTTP/3 (browser <-> edge) stays on and is unaffected by this change - only the cloudflared <-> edge link drops from QUIC to HTTP/2.
Why this matters
HA + Cloudflare Tunnel + Cloudflare Access is one of the most commonly recommended self-hosted configurations. When this fires, the failure is silent on the user side - the Service-Worker-cached frontend keeps loading, automations keep running on the server, but every dashboard ends up displaying "Unable to connect to Home Assistant" forever, and users have no obvious path from that symptom to "drop QUIC".
Ask
Either fix the QUIC <-> HTTP/1.1 translation so
Upgrade: websocketpropagates correctly, or document explicitly that--protocol=http2is required for WebSocket origins. The README for the HA cloudflared add-on does not currently mention this.Update — 2026-05-29: symptom recurred despite
--protocol=http2The workaround documented above is unreliable. Today (2026-05-29 UTC) the exact same symptom returned without any user-side change:
--protocol=http2. Add-on log line at startup:INF Settings: map[... p:http2 protocol:http2]followed byINF Initial protocol http2, and everyRegistered tunnel connectionline showsprotocol=http2(4 of them, to fra03/hel01/hel02). Noquicanywhere in the cloudflared log.wss://.../api/websocketcloses withcode: 1006, wasClean: false, ~278 ms, noopenevent. Plain GET to/api/websocketreturns the same HA aiohttp 400 with bodyNo WebSocket UPGRADE hdr: None.LAXedge POP both during the broken state and after self-recovery, so the variable isn't the geographic edge.This means
--protocol=http2is not a reliable workaround — the bug surfaces on HTTP/2 transport too. The QUIC path may amplify it, but the failure isn't purely in the QUIC ↔ HTTP/1.1 translation. Something upstream of (or at) cloudflared on the HTTP/2 path is also affected.Correlation worth flagging
Both occurrences I've seen sit immediately after a Cloudflare managed DDoS L7 ruleset push:
3299→3300on 2026-05-22T11:49:51Z.3300→3301on 2026-05-28T18:35:29Z (~10 hours before the symptom became visible).Two correlated events isn't proof, but the timing has the shape of a managed-ruleset regression that gets rolled back — both times the symptom resolved without user action, and nothing changed on my side in between. If anyone on the cloudflared side can correlate with internal DDoS L7 rule changes around those timestamps, that would close the loop.
Updated ask
Upgrade: websocketheader (transiently or otherwise), not just QUIC.Related reading
A write-up of the symptom and the same fix, plus a browser-side detection probe for the upgrade-stripped signature, lives at: https://github.com/mayerwin/HA-Cloudflare-Access-Recovery#second-failure-mode-websocket-upgrade-stripping