cloudflare / cloudflare/cloudflare-docs

Access on a Worker destination silently blocks WebSockets (403 on upgrade)

Open
#31,885 1 comment 0 reactions 0 assignees View on GitHub
product:network stale
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.