Skip to content

WebSocket Upgrade: websocket header dropped at origin when cloudflared is on default --protocol=auto (QUIC); --protocol=http2 is a reliable workaround #1652

@mayerwin

Description

@mayerwin

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 32993300 on 2026-05-22T11:49:51Z.
  • This occurrence (2026-05-29): DDoS L7 ruleset pushed from 33003301 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions