docs: ambient credentials versus challenge-response, which is the real rule

Copilot's fourth pass, and it caught this text CONTRADICTING ITSELF three
paragraphs apart -- in prose I wrote in a single commit while fixing the
previous round's contradiction.

The Caddyfile comment said "the browser cannot send a credential on a
WebSocket handshake". That is FALSE, and the same section already said so: the
Cloudflare Access bullet explains that Access works by a session COOKIE the
handshake carries. A WebSocket handshake is an HTTP request; the browser
attaches ambient credentials to it automatically.

The true rule, which is sharper than the one it replaces and explains both
halves instead of one:

  AMBIENT credentials are carried -- cookies for that host above all, which is
  why an identity-aware proxy works at all.
  CHALLENGE-RESPONSE cannot be satisfied -- the page can set no header (there
  is no Authorization argument to `new WebSocket`) and can answer no prompt (a
  handshake surfaces no login dialog and fails to script as a bare connection
  error).

So basic auth is still ruled out, for a REASON rather than by an over-broad
invariant, and Access is no longer an unexplained exception to it.

Two consequences carried through the rest of the section, on the grep
discipline the last round earned:
 * mTLS is no longer asserted to fail. The certificate IS offered at the TLS
   layer rather than by the page; only the selection UI is doubtful, and that
   is now marked as not verified here rather than stated.
 * "a query parameter is the one credential new WebSocket can carry" restated
   the same over-broad claim in miniature, and becomes "the one place the page
   can put a credential DELIBERATELY -- everything else it sends is ambient."

1193 passing, unchanged; no code touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-09-19 23:03:01 -07:00
parent f4600eb277
commit 1c98aa0929
+27 -13
View File
@@ -248,13 +248,24 @@ proxy. TLS gets you confidentiality, not access control.
**And the control has to be one a browser can actually satisfy**, which rules
out most of the obvious ones. The page opens the socket as
`new WebSocket(url)` — one argument, a URL. The browser WebSocket API **cannot
set request headers**, and a WebSocket handshake shows **no login prompt**, so
HTTP basic auth in front of it is not a locked door, it is a **401 the page
cannot answer and cannot explain** — the same reason a self-signed certificate
is unusable here (see "Why not native `wss://`"). Client-certificate mTLS has
the same problem: the selection UI browsers show for a top-level navigation is
not something a cross-origin socket handshake can rely on.
`new WebSocket(url)` — one argument, a URL — and the distinction that decides
everything here is **ambient credentials versus challenge–response**:
- The handshake **is** an HTTP request, so the browser attaches what it
attaches *automatically* — cookies for that host above all. That is why an
identity-aware proxy can work at all.
- What the page cannot do is **set a header** (there is no `Authorization`
argument to `new WebSocket`) or **answer a prompt** (a handshake surfaces no
login dialog, and its failure reaches script as a bare connection error).
So **HTTP basic auth is not a locked door here, it is a 401 the page can
neither answer nor explain** — the same shape as the self-signed certificate
that rules out native `wss://` below. Anything challenge-shaped fails for that
reason; anything ambient may work. Client-certificate mTLS sits awkwardly
between the two — the certificate is offered at the TLS layer rather than by
the page, but the selection UI browsers show is a top-level-navigation
affordance, and whether a cross-origin socket handshake gets it is **not
something verified here**.
So the honest controls are the ones that need **no credential from the page**:
@@ -262,15 +273,18 @@ So the honest controls are the ones that need **no credential from the page**:
below. It is about where the client is, so the page sends nothing.
- **A private network** (Tailscale, WireGuard). The peer is only reachable by
machines already on it; again the page sends nothing.
- **An identity-aware tunnel** (`cloudflared` with Cloudflare Access). This one
- **An identity-aware tunnel** (`cloudflared` with Cloudflare Access) — the
*ambient* case above, and the reason that distinction is drawn at all. It
works by a session **cookie** set after an interactive login on the proxy's
own hostname, so it needs the user to visit that hostname first, and it is
own hostname, which the handshake then carries by itself; so it needs the
user to visit that hostname first, and it is
subject to the browser's third-party-cookie rules. Plausible and *not
verified here* — there is no browser in this repository — so it is named, not
written out as a recipe.
What would make this genuinely easy is a **token in the URL**, because a query
parameter is the one credential `new WebSocket(url)` can carry. The server has
What would make this genuinely easy is a **token in the URL**, because the URL
is the one place the page can put a credential *deliberately* — everything else
it sends is ambient. The server has
no such capability today, and whether it should grow one — or whether "auth is
the proxy's job" becomes a stated design position — is **improvise-kpnr**.
@@ -284,8 +298,8 @@ renews the certificate itself. Point a DNS name at the host, then:
# Caddyfile
improvise.example.com {
# NOT optional — see above. Without it the document is world-writable.
# An allow-list, not a password: the browser cannot send a credential on a
# WebSocket handshake, so this is checked against the client's address.
# An allow-list rather than a password, because the page cannot answer a
# challenge on a socket handshake: this is checked against the address.
@denied not remote_ip 203.0.113.4 198.51.100.0/24
abort @denied