cloudflare / cloudflare/cloudflare-docs
Access on a Worker destination silently blocks WebSockets (403 on upgrade)
- Dominant language
- MDX
- Stars
- 5.2k
- Forks
- 16.7k
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 337
Description
### What is the issue?
When Cloudflare Access is enabled on a Worker via the one-click **Enable Cloudflare Access** button (Workers → Settings → Domains & Routes), the Access application it creates targets the Worker directly – a *Worker destination* (`destinations: [{ "type": "worker", "worker_id": "…" }]`). In this configuration, the Access edge returns `403` to any request carrying WebSocket upgrade headers and never forwards the upgrade to the Worker. Regular HTTP requests pass through normally.
The effect is a Worker whose REST endpoints work but whose WebSocket endpoints fail during the handshake with an opaque `403`. Nothing in the dashboard or the docs indicates that WebSockets behave differently for a Worker-destination application, so the failure is very hard to attribute – the `403` has no `cf-mitigated` header (so it does not look like a WAF/bot mitigation) and an empty body (so it does not look like an application error).
### Evidence
Same host, same authenticated session; the only variable is the presence of upgrade headers:
| Request | Auth | Result |
|---|---|---|
| `GET /path` (normal) | valid cookie | `200` |
| `GET /ws` (normal) | valid cookie | `426 Upgrade Required` (reaches the Worker) |
| `GET /ws` (normal) | none | `302` → Access login |
| `GET /ws` **+ `Upgrade: websocket`** | valid cookie | **`403`, empty body** |
| `GET /ws` **+ `Upgrade: websocket`** | none | **`403`, empty body** |
| `GET /path` **+ `Upgrade: websocket`** | valid cookie | **`403`, empty body** |
Two points isolate the cause to the Worker-destination model specifically, rather than the WAF or Access in general:
1. **Not the WAF / zone rules.** A hostname on the same zone that is not behind Access answers a WebSocket-upgrade request with its normal `200`, so no zone rule is blocking upgrade headers.
2. **Not Access in general.** A classic self-hosted Access application that targets a *hostname* (a `public` destination) sends WebSocket upgrades through the normal flow – `302` → login when unauthenticated, then proxies the upgrade after authentication. Only an application targeting a Worker destination returns the flat `403`.
The `403` is emitted at the Access proxy layer before the identity check (an unauthenticated upgrade gets `403`, not the `302`-to-login that a normal unauthenticated request gets), so it is independent of the policy configuration.
### Workaround
Point the Access application at the public hostname instead of the Worker. Update it in place (deleting and recreating mints a new `aud`, which breaks JWT validation and any service tokens):
```
PUT /accounts/{account_id}/access/apps/{app_id}
{
"type": "self_hosted",
"destinations": [{ "type": "public", "uri": "" }]
// preserve name, session_duration, allowed_idps, cookie settings, tags,
// and reattach reusable policies as { "id", "precedence" }
}
```
After the change the upgrade returns `101 Switching Protocols` and the socket connects. The `aud` is unchanged, so `Cf-Access-Jwt-Assertion` validation and service tokens keep working.
### Suggested documentation change
Add a limitation callout where protecting a Worker with Access is described:
- The Workers **Domains & Routes → Enable Cloudflare Access** documentation and the *One-click Cloudflare Access for Workers* changelog entry (2025-10-03).
- The Cloudflare One **self-hosted applications** page, where destination types (`public` vs `worker`) are described.
- A cross-link from the **WebSockets** ([network/websockets](https://developers.cloudflare.com/network/websockets/)) page.
Proposed text:
> **WebSockets and Worker-destination applications.** When an Access application targets a Worker directly (a *Worker destination*, including applications created by the one-click **Enable Cloudflare Access** button on a Worker), Access does not proxy WebSocket connections – upgrade requests are rejected with `403`. Regular HTTP requests are unaffected. If your Worker serves WebSockets, protect it with a self-hosted application that targets the public hostname (a `public` destination) instead of, or in addition to, the Worker destination.
### Note
If Worker-destination applications are intended to proxy WebSockets, this is a proxy-side bug rather than only a docs gap, and the `403` should be replaced with the same auth flow the hostname model uses. Either way a docs note would help until the behaviour is consistent. Only the `worker` destination type was tested; `preview_worker` / `all_workers` may or may not behave the same.
Contributor guide
Research direction
Start with the Workers Domains & Routes documentation and the One-click Cloudflare Access for Workers changelog entry, then review the self-hosted applications and WebSockets pages for the relevant destination guidance. Add the proposed limitation callout and cross-links, preserving the documented public-destination workaround, then run the documentation build or link checks to confirm the pages render correctly.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- markdown
- Domain
- documentation
- Issue type
- Documentation
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100