Skip to content

[miniflare] Authenticate remote bindings with Cloudflare Access service tokens - #14198

Closed
krys-cf wants to merge 4 commits into
cloudflare:mainfrom
krys-cf:fix/remote-bindings-access-service-token
Closed

[miniflare] Authenticate remote bindings with Cloudflare Access service tokens#14198
krys-cf wants to merge 4 commits into
cloudflare:mainfrom
krys-cf:fix/remote-bindings-access-service-token

Conversation

@krys-cf

@krys-cf krys-cf commented Jun 5, 2026

Copy link
Copy Markdown

What

When wrangler dev (or the Vite plugin) uses remote bindings against a Worker whose *.workers.dev domain is protected by Cloudflare Access, requests from the local remote-bindings proxy client to the remote proxy server are rejected with a 401/403. This breaks every remote binding behind Access — Workers AI, AI Gateway, Vectorize, Images, Artifacts, etc.

This builds on #14008 / #14011 (which fixed Access service-token auth for the realish-preview HTTP path and added a block warning) by also authenticating the binding proxy traffic itself. There are two distinct code paths and both needed fixing:

Path Used by Fix
HTTP makeFetch wrapped-fetcher bindings: AI, Vectorize, Images Attach CF-Access-Client-Id / CF-Access-Client-Secret headers to the request to the proxy server
capnweb WebSocket makeRemoteProxyStub RPC bindings: Artifacts, service bindings Establish the WebSocket via a fetch() upgrade (Upgrade: websocket) so the Access headers ride the handshake — new WebSocket(url) cannot set request headers in the Workers runtime

Credentials are read from CLOUDFLARE_ACCESS_CLIENT_ID / CLOUDFLARE_ACCESS_CLIENT_SECRET — the same Service Token env vars that getAccessHeaders() already uses for the realish-preview HTTP path (packages/wrangler/src/user/access.ts), so this is consistent with existing wrangler conventions. They're forwarded to the proxy client worker as text bindings (accessClientId / accessClientSecret).

When the env vars are unset, behaviour is unchanged.

Why the WebSocket change

capnweb's newWebSocketRpcSession accepts either a URL string (which it upgrades with new WebSocket(url) — no header support) or a pre-connected WebSocket. When Access credentials are present we do the upgrade ourselves via fetch(httpUrl, { headers: { Upgrade: "websocket", ...accessHeaders } }) and hand the resulting response.webSocket to capnweb. RPC methods are async over the network, so awaiting the authenticated upgrade is transparent to callers (e.g. await env.ARTIFACTS.<method>()).

Testing

Verified end-to-end against a real internal app (D1/Vectorize/AI/Artifacts, all remote: true) on a workers.dev account protected by an Access policy:

  • Workers AI + AI Gateway (course generation) — was failing with InferenceUpstreamError: invalid_token, now succeeds
  • Vectorize query + AI embeddings (vector search) — returns ranked results
  • Artifacts git read/write over capnweb RPC — commits succeed and content reads back
  • ✅ Confirmed by elimination: patch + creds → works; creds only → invalid_token; patch only → invalid_token
  • pnpm --filter miniflare build passes with 0 type errors

Note: I'm happy to add automated coverage if a maintainer can point me at the right harness — the existing remote-bindings paths are largely exercised in staging/manual since they require a real Access-protected workers.dev origin. Verified manually as above.


Open in Devin Review

…ce tokens

When the workers.dev domain is behind Cloudflare Access, remote bindings (AI, Vectorize, Images, Artifacts, etc.) failed with 401/403 because the proxy client never sent Access credentials.

Attach CF-Access-Client-Id / CF-Access-Client-Secret (from CLOUDFLARE_ACCESS_CLIENT_ID / CLOUDFLARE_ACCESS_CLIENT_SECRET env vars) to both:
- the HTTP makeFetch path (wrapped-fetcher bindings: AI, Vectorize, Images)
- the capnweb WebSocket path (RPC bindings: Artifacts) via a fetch()-based Upgrade so headers ride the handshake (new WebSocket(url) cannot set headers)

Consistent with getAccessHeaders() which already reads these env vars for the realish-preview HTTP path. No behavior change when the env vars are unset.
@krys-cf
krys-cf requested a review from workers-devprod as a code owner June 5, 2026 14:10
@changeset-bot

changeset-bot Bot commented Jun 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8ca8bdc

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
miniflare Patch
wrangler Patch
@cloudflare/pages-shared Patch
@cloudflare/vite-plugin Patch
@cloudflare/vitest-pool-workers Patch
@cloudflare/wrangler-bundler Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@workers-devprod
workers-devprod requested review from a team and james-elicx and removed request for a team June 5, 2026 14:10
@workers-devprod

workers-devprod commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • @cloudflare/wrangler
Show detailed file reviewers
  • .changeset/remote-bindings-access-service-token.md: [@cloudflare/wrangler]
  • packages/miniflare/src/plugins/shared/constants.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/plugins/shared/index.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/dispatch-namespace/dispatch-namespace-proxy.worker.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/shared/remote-bindings-utils.ts: [@cloudflare/wrangler]
  • packages/miniflare/src/workers/shared/remote-proxy-client.worker.ts: [@cloudflare/wrangler]
  • packages/miniflare/test/plugins/shared/remote-bindings-access-headers.spec.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/api/remoteBindings/start-remote-proxy-session.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/dev/remote-bindings.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts: [@cloudflare/wrangler]

devin-ai-integration[bot]

This comment was marked as resolved.

- Return undefined for 'then' while the capnweb WebSocket is connecting so the proxy is not treated as a thenable (an await would otherwise dispatch a bogus remote 'then' RPC).
- Attach a no-op .catch() to stubPromise so a failed WS upgrade isn't an unhandled rejection when only the .fetch() path is used; the error still surfaces on RPC access via the awaited stubPromise.
@pkg-pr-new

pkg-pr-new Bot commented Jun 8, 2026

Copy link
Copy Markdown
create-cloudflare

npm i https://pkg.pr.new/create-cloudflare@14198

@cloudflare/deploy-helpers

npm i https://pkg.pr.new/@cloudflare/deploy-helpers@14198

@cloudflare/kv-asset-handler

npm i https://pkg.pr.new/@cloudflare/kv-asset-handler@14198

miniflare

npm i https://pkg.pr.new/miniflare@14198

@cloudflare/pages-shared

npm i https://pkg.pr.new/@cloudflare/pages-shared@14198

@cloudflare/unenv-preset

npm i https://pkg.pr.new/@cloudflare/unenv-preset@14198

@cloudflare/vite-plugin

npm i https://pkg.pr.new/@cloudflare/vite-plugin@14198

@cloudflare/vitest-pool-workers

npm i https://pkg.pr.new/@cloudflare/vitest-pool-workers@14198

@cloudflare/workers-auth

npm i https://pkg.pr.new/@cloudflare/workers-auth@14198

@cloudflare/workers-editor-shared

npm i https://pkg.pr.new/@cloudflare/workers-editor-shared@14198

@cloudflare/workers-utils

npm i https://pkg.pr.new/@cloudflare/workers-utils@14198

wrangler

npm i https://pkg.pr.new/wrangler@14198

@cloudflare/wrangler-bundler

npm i https://pkg.pr.new/@cloudflare/wrangler-bundler@14198

commit: 8ca8bdc

@petebacondarwin
petebacondarwin requested review from petebacondarwin and removed request for james-elicx June 8, 2026 11:58

@petebacondarwin petebacondarwin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tackling this @krys-cf. It looks like it will solve the final missing piece of this puzzle.

I will have a quick think about how we can add tests for this.

One thing I would like to change before we land is how the credentials enter Miniflare.

The PR reads CLOUDFLARE_ACCESS_CLIENT_ID / CLOUDFLARE_ACCESS_CLIENT_SECRET from process.env inside packages/miniflare/src/plugins/shared/constants.ts. I noticed that mirrors the existing process.env.CF_TRACE_ID read on the line above, so there's clearly precedent — I don't think we should lean into that precedent.

  • @cloudflare/workers-auth's getAccessHeaders(domain) is the canonical helper that the realish-preview hop already goes through (packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts:325). On top of the two env vars it also covers:
    • Interactive cookie auth via cloudflared access login → Cookie: CF_Authorization=…
    • "Only one of two vars set" warning
    • Non-interactive UserError with the Service Tokens docs link
      Reading the env directly skips all three.
      I'm mostly curious about the cookie case — both hops terminate on workers.dev and are gated by the same Access policy, so a user who's authenticated via cloudflared for the realish-preview will still see 403s on the binding hop, right? Or am I missing something?
  • More generally, having Miniflare reach into process.env for wrangler-flavoured variable names feels like it's pulling the abstraction backwards a little — Vite plugin / vitest-pool-workers / getPlatformProxy / programmatic users all touch this code path, and they may not have those vars set even when they're configured for remote bindings via the wrangler API.

Since both hops want the same headers, startRemoteProxySession() could compute them once via getAccessHeaders(remoteProxyConnectionString.hostname) and expose them on the returned session as an opaque header bag, which Miniflare then forwards on the binding traffic:

// wrangler: start-remote-proxy-session.ts
export type RemoteProxySession = Pick<Worker, "ready" | "dispose"> & {
    updateBindings: (bindings: StartDevWorkerInput["bindings"]) => Promise<void>;
    remoteProxyConnectionString: RemoteProxyConnectionString;
    remoteProxyHeaders?: Record<string, string>;
};
// miniflare: CoreSharedOptionsSchema
remoteProxyHeaders: z.record(z.string()).optional(),

Then remoteProxyClientWorker() takes the headers as a parameter (forwarded as a single JSON text binding so the worker side stays generic for either the Service Token pair or a Cookie), each plugin's getServices() passes sharedOptions.remoteProxyHeaders in, and makeFetch / makeRemoteProxyStub / connectWebSocketWithHeaders just spread the bag onto outgoing requests instead of hard-coding CF-Access-Client-Id / CF-Access-Client-Secret.

Comment on lines +310 to +314
// While the WebSocket is still connecting, this branch handles
// every property access. Return `undefined` for `then` so the
// proxy is not mistaken for a thenable — otherwise `await`-ing
// the stub (or any implicit thenable check) would invoke the
// wrapper for `then` and dispatch a bogus remote `then` RPC.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It took me a while to get my head around this comment.
So what we are actually saying here is:

  • While we wait for the stub promise to resolve, we are returning a function that when called will wait for the stub and then delegate to the necessary stub property/method.
  • In normal processing, capnweb will always return undefined for the then property on the top level stub. So we must not return the above function if the requested property is then.

@github-project-automation github-project-automation Bot moved this from Untriaged to In Review in workers-sdk Jun 8, 2026
@petebacondarwin

petebacondarwin commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Regarding testing, the remote-bindings paths are actually testable locally without a real Access-protected origin.

There's already most of what's needed:

  • useServer() (packages/miniflare/test/test-shared/http.ts) spins up a local HTTP server, and if you pass a second arg it also starts a ws server whose listener receives the upgrade req — so you can assert on the handshake headers directly. That covers the WebSocket path.
  • packages/miniflare/test/plugins/shared/remote-bindings-access-warning.spec.ts is a working example of driving a real Miniflare against a fake proxy server via serviceBindings: { SERVICE: { name, remoteProxyConnectionString: <local url> } } and asserting on what the proxy received. The HTTP (makeFetch) path can extend this pattern almost verbatim.
  • On the wrangler side, src/tests/dev/remote-bindings.test.ts already mocks startRemoteProxySession and captures the exact options handed to Miniflare, and RemoteRuntimeController.test.ts shows the vi.mock(... access ...) + mockResolvedValue(...) pattern for faking getAccessHeaders().

So a real workers.dev Access origin is really only needed for a true e2e — the plumbing itself can be unit-tested.

A suggested set of layers:

  1. Miniflare, HTTP path — fake proxy server records req.headers; assert the Access headers arrive when configured and are absent when not. Include a Cookie variant alongside the CF-Access-Client-* pair (see point on cookie auth below).
  2. Miniflare, WebSocket path — useServer(httpListener, wsListener); the WS listener captures the upgrade req.headers. The fetch()-based upgrade fires before any RPC, so the handshake carries the headers even though the fake server doesn't speak capnweb (the RPC call can just be swallowed). This also implicitly exercises the then-guard.
  3. Wrangler, plumbing — extend remote-bindings.test.ts to assert the headers reach the Miniflare options.
  4. Wrangler, getAccessHeaders() wiring — assert the session actually calls getAccessHeaders(<proxy host>) and attaches the result.

One thing layer 4 would surface: if the headers come from getAccessHeaders() rather than being read straight from the two env vars, the cookie-auth case (cloudflared access login → Cookie: CF_Authorization=…) is covered too, which is the gap I mentioned earlier. A mockResolvedValue({ Cookie: "..." }) test would lock that in.

And if the headers end up traveling with remoteProxyConnectionString (per-binding) rather than as a single instance-wide option, it'd be worth one multiworker-style test with two workers pointing at two different proxy servers, asserting each carries its own headers — multiworker dev builds per-worker proxy sessions into a single Miniflare instance, so that's the one spot where instance-global creds would send the wrong token to the wrong proxy.

@petebacondarwin

Copy link
Copy Markdown
Contributor

@krys-cf - would you like me to help you make these changes and add the tests?

@krys-cf

krys-cf commented Jun 8, 2026

Copy link
Copy Markdown
Author

@petebacondarwin would appreciate the help! Anything to get this over the line. 👍

…ce then-guard with a capnweb transport

- Resolve the proxy host's Access headers wrangler-side via the canonical
  getAccessHeaders() helper (service token + cloudflared cookie + warnings)
  instead of reading process.env inside Miniflare.
- Carry the opaque header bag on the RemoteProxyConnectionString so it travels
  per-connection (multiworker-correct) and forward it as a single JSON binding.
- Drive the capnweb WebSocket upgrade through a custom RpcTransport so the stub
  is created synchronously, removing the thenable guard and orphaned-rejection
  workaround.
- Add tests: Miniflare HTTP (service-token + cookie + none) and WebSocket
  handshake; wrangler getAccessHeaders wiring, per-host resolution, and the
  Miniflare options plumbing.
@krys-cf

krys-cf commented Jun 8, 2026

Copy link
Copy Markdown
Author

Thanks @petebacondarwin — took a pass at all of this and just pushed. Summary of what changed and two things I'd like your read on before we call it done.

Credentials now come from getAccessHeaders(), not process.env. startRemoteProxySession() resolves the Access headers for the proxy server's host via the canonical helper and they ride down to Miniflare, which forwards them on the binding traffic as a single opaque JSON binding (so the worker side stays auth-scheme-agnostic). To your cookie question: yes — the cloudflared cookie hop was broken before; it now flows through, and there's a test for it.

Decision 1 — where the header bag lives. Your sketch put it on CoreSharedOptionsSchema (instance-wide). I went a different way: the bag travels on the RemoteProxyConnectionString object itself. Two reasons: (a) it's multiworker-correct — a single Miniflare instance hosts several workers whose sessions can target different Access-protected hosts, and an instance-wide field would send the wrong token to the wrong proxy (the exact case you flagged in the testing notes); (b) wrangler already stamps the same connStr object by reference onto every remote binding, so this needs ~0 changes across the 24 plugin schemas. Happy to switch to the instance-wide shape if you'd prefer, but this felt both safer and smaller.

Decision 2 — the then-guard is gone. Rather than keep the lazy-stub proxy (and the comment you had to puzzle over), I moved the async WebSocket upgrade into a small capnweb RpcTransport. new RpcSession(transport).getRemoteMain() returns a real stub synchronously, so the wrapper proxy collapses back to "intercept .fetch only" and capnweb's own stub handles then/error propagation. This also removed the orphaned-rejection .catch(). Net: the auth path is unchanged, but there's no bespoke thenable handling to reason about.

capnweb (not a blocker): the underlying gap is that newWebSocketRpcSession() only takes an open socket or a URL string (and new WebSocket(url) can't set request headers in workerd). The clean upstream fix would be to accept a Promise<WebSocket>; I'm happy to file that as a follow-up so we can eventually drop the custom transport. The public RpcSession / RpcTransport API already covers us today, so it's not required for this PR.

Tests (all using the harnesses you pointed at — thank you, that saved a ton of time): Miniflare HTTP path (service-token + cookie + none-configured), Miniflare WebSocket handshake, wrangler getAccessHeaders wiring (incl. a mockResolvedValue({ Cookie }) case) and per-host resolution, plus the plumbing assertion that the headers reach the Miniflare options. The one I haven't done is the full multiworker dev integration test — the carrier design makes the instance-global failure mode structurally impossible, so I left it as the spot to pair on if you're still up for it.

Let me know on the two decisions above and I'll square away the multiworker test.

devin-ai-integration[bot]

This comment was marked as resolved.

getAccessHeaders() can throw (non-interactive UserError without a service
token, or a cloudflared failure). On that path — and on the pre-existing
worker-error path — the started proxy worker was never disposed, leaking the
process for callers that catch and continue (e.g. getPlatformProxy). Dispose
before re-throwing. Adds a regression test.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 new potential issues.

View 8 additional findings in Devin Review.

Open in Devin Review

Comment on lines +159 to +161
remoteProxyHeaders = await getAccessHeaders(
remoteProxyConnectionString.hostname
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 getAccessHeaders called with localhost hostname instead of workers.dev hostname, making the feature a no-op

worker.url resolves to the local ProxyWorker URL (e.g. http://127.0.0.1:PORT/), not the remote workers.dev URL. So remoteProxyConnectionString.hostname at line 160 evaluates to 127.0.0.1 or localhost. Calling getAccessHeaders("127.0.0.1") always returns {} because localhost is never behind Cloudflare Access, meaning hasRemoteProxyHeaders is always false and no auth headers are ever attached to the connection string.

The test at packages/wrangler/src/__tests__/api/remoteBindings/start-remote-proxy-session.test.ts passes only because it mocks startWorker to return url: Promise.resolve(new URL("https://proxy-a.example.workers.dev/")) — a workers.dev URL — but the real startWorkerDevEnvProxyController creates a local Miniflare instance whose .ready (and thus worker.url) resolves to a local address (ProxyController.ts:228 passes the local Miniflare URL to emitReadyEvent). The entire Access-header feature added by this PR is therefore inert in production.

Prompt for agents
The getAccessHeaders call on line 159-161 uses remoteProxyConnectionString.hostname, but remoteProxyConnectionString comes from worker.url which is the local ProxyWorker URL (e.g. http://127.0.0.1:PORT/). The hostname is always 127.0.0.1 or localhost, never the workers.dev domain, so getAccessHeaders always returns empty headers.

The correct hostname is the remote workers.dev host where the proxy server worker is deployed. This hostname is known to the RemoteRuntimeController as token.host (see RemoteRuntimeController.ts line 325-335 where it already resolves Access headers for the preview hop). You need to either:

1. Extract the workers.dev hostname from the DevEnv/RemoteRuntimeController after the remote worker is deployed and use that for the getAccessHeaders call, OR
2. Expose the remote host from the Worker object returned by startWorker so startRemoteProxySession can access it.

Note that the RemoteRuntimeController already handles Access headers for its own preview-token hop (line 325). Consider whether the binding-proxy traffic also needs separate Access headers, or if the existing ProxyController forwarding already handles it (since binding client workers talk to the local proxy, which then forwards to the remote server with the preview token + access headers from proxyData).
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@krys-cf - this is the conclusion I was coming to as well. I am putting together a test to prove this out.

Comment on lines +347 to +359
abort(reason: unknown): void {
if (this.#socket) {
const message = reason instanceof Error ? reason.message : `${reason}`;
try {
this.#socket.close(3000, message);
} catch {
// best-effort
}
}
if (this.#error === undefined) {
this.#error = reason;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 FetchWebSocketTransport.abort() does not reject a pending receive() promise, causing potential hang

When abort(reason) is called while a receive() promise is pending (i.e. #receiveRejecter is set), the method sets this.#error but does not call #receiveRejecter(reason). The subsequent WebSocket close event triggers #receivedError, but that method's guard if (this.#error === undefined) prevents it from rejecting the pending promise since abort() already set #error. The pending receive() promise thus never resolves or rejects.

Sequence causing the hang
  1. capnweb calls receive() → creates a Promise with #receiveResolver/#receiveRejecter
  2. capnweb calls abort(error) → closes socket, sets #error = error, but does NOT call #receiveRejecter
  3. The close event fires → #receivedError called → #error !== undefined → no-op
  4. The Promise from step 1 never settles → hang
Suggested change
abort(reason: unknown): void {
if (this.#socket) {
const message = reason instanceof Error ? reason.message : `${reason}`;
try {
this.#socket.close(3000, message);
} catch {
// best-effort
}
}
if (this.#error === undefined) {
this.#error = reason;
}
}
abort(reason: unknown): void {
if (this.#socket) {
const message = reason instanceof Error ? reason.message : `${reason}`;
try {
this.#socket.close(3000, message);
} catch {
// best-effort
}
}
if (this.#error === undefined) {
this.#error = reason;
if (this.#receiveRejecter) {
this.#receiveRejecter(reason);
this.#receiveResolver = undefined;
this.#receiveRejecter = undefined;
}
}
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +9 to +28
let proxyUrl = new URL("https://proxy-a.example.workers.dev/");
// Captures the most recently created fake worker so tests can assert on its
// `dispose` (e.g. the cleanup-on-throw path).
let lastFakeWorker: ReturnType<typeof makeFakeWorker> | undefined;
function makeFakeWorker() {
const worker = {
ready: Promise.resolve(),
url: Promise.resolve(proxyUrl),
dispose: vi.fn(async () => {}),
patchConfig: vi.fn(async () => {}),
raw: {
addListener: vi.fn(),
proxy: {
localServerReady: { promise: Promise.resolve() },
runtimeMessageMutex: { drained: vi.fn(async () => {}) },
},
},
};
lastFakeWorker = worker;
return worker;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Test mocks don't reflect production worker.url behavior

The unit test at packages/wrangler/src/__tests__/api/remoteBindings/start-remote-proxy-session.test.ts:9 sets proxyUrl = new URL("https://proxy-a.example.workers.dev/") and wires it as the fake worker's .url. In production, worker.url resolves from DevEnv.proxy.ready.promise.then((ev) => ev.url) (DevEnv.ts:214), which is the local Miniflare ProxyWorker's listen address (e.g. http://127.0.0.1:PORT/). The mock therefore doesn't exercise the real hostname resolution path, masking BUG-0001. Consider using a localhost URL in the mock and separately testing the hostname lookup.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +138 to +169
// Authenticate the binding-proxy hop the same way the realish-preview hop is
// authenticated: resolve Access headers for the proxy server's host via the
// canonical `getAccessHeaders()` helper (service token, the
// "only one var set" warning, the non-interactive UserError, and interactive
// `cloudflared` cookie auth all come for free) and carry them on the
// connection string so Miniflare attaches them to every request it makes to
// the proxy server. Both hops terminate on the same workers.dev host behind
// the same Access application, so the lookup is typically already cached.
//
// Imported lazily: a static import pulls the `@cloudflare/workers-auth` /
// `@cloudflare/workers-utils` chain into this module's eval, which perturbs
// module init order for importers that only need the proxy session (e.g.
// tests that mock this function), so keep it off the module load path.
//
// `getAccessHeaders()` can throw (a non-interactive `UserError` when the host
// is behind Access with no service token, or a `cloudflared` failure), so
// dispose the already-started worker before propagating to avoid leaking the
// proxy process for callers that catch and continue (e.g. getPlatformProxy).
let remoteProxyHeaders: Record<string, string>;
try {
const { getAccessHeaders } = await import("../../user/access");
remoteProxyHeaders = await getAccessHeaders(
remoteProxyConnectionString.hostname
);
} catch (error) {
await worker.dispose().catch(() => {});
throw error;
}
const hasRemoteProxyHeaders = Object.keys(remoteProxyHeaders).length > 0;
if (hasRemoteProxyHeaders) {
remoteProxyConnectionString.remoteProxyHeaders = remoteProxyHeaders;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Existing RemoteRuntimeController already handles Access headers for the preview hop

At RemoteRuntimeController.ts:325, the existing code already calls getAccessHeaders(token.host) with the correct workers.dev hostname and includes the result in proxyData.headers (line 335). The ProxyWorker then attaches these headers when forwarding requests to the remote server. This means the hop from the local ProxyController to the remote workers.dev worker is already authenticated. The new code in this PR attempts to add Access headers to the hop from binding client workers to the local ProxyController — but that hop is localhost-to-localhost and doesn't traverse Access. It's worth investigating whether there's actually a scenario where Access headers are needed on the client→local-proxy hop, or whether the existing mechanism fully covers all cases.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

petebacondarwin added a commit that referenced this pull request Jun 9, 2026
…forward-header instrumentation

Adds a credentialed E2E repro for PR #14198 (companion to the baseline tests
in the rest of this branch). One `wrangler dev` worker exercises both
remote-binding proxy code paths and reports each independently in a single
JSON response, so a single request tells us which path works/fails and why:

  - `ai`           → HTTP `makeFetch` (wrapped fetcher — the AI
                       `invalid_token` case in #14198)
  - `serviceFetch` → HTTP `makeFetch` (service binding `.fetch`)
  - `rpc`          → WebSocket `makeRemoteProxyStub` (capnweb / RPC — the
                       Artifacts case in #14198)

The repro lives at `investigation/remote-access-repro/`, outside the
pnpm-workspace globs (so CI, lint, type-check, fixture validation, and
turbo all skip it). It is **manual, account-dependent**: it requires
Workers AI, a deployed target worker, Access on the account's workers.dev
subdomain with a Service Auth policy, and Service Token creds. The README
documents prereqs, the run protocol, scenario matrix (A: main no creds,
B: main + creds [the decisive run], C/D: PR #14198 +/- creds), and the
interpretation table.

Also adds a THROWAWAY debug log in
`packages/wrangler/templates/startDevWorker/ProxyWorker.ts` — grep for
`mf-access-debug` — that logs, for every request the local ProxyWorker
forwards to the workers.dev edge (HTTP and WebSocket upgrades alike),
whether the merged headers carry the Access service-token / cookie / preview
token. This is the smoking gun for the central question: do the Access
headers reach the edge on the WS-upgrade hop? **Must be reverted before any
non-investigation merge** — it is a `console.log` in a template, gated only
by being in this debug branch.
@krys-cf

krys-cf commented Jun 9, 2026

Copy link
Copy Markdown
Author

Closing — superseded by released behavior.

After end-to-end verification, remote bindings (Workers AI, Vectorize, Artifacts) work behind Cloudflare Access on the current released toolchain (wrangler@4.99.0, miniflare@4.20260609.0, @cloudflare/vite-plugin@1.40.1) without the changes in this PR.

Verified two ways:

  1. A minimal @cloudflare/vite-plugin repro exercising all three binding types behind Access — passes with a service token in .env; fails fast with a clear "behind Access, no service token (non-interactive)" error when the token is absent (so Access is genuinely enforced and the token path works).
  2. The real app that originally hit this (an internal application) — after removing its hand-rolled equivalent patch and upgrading to bare latest, the remote-proxy session establishes behind Access (Using Access Service Token headers for domain: …) and AI/Vectorize/Artifacts round-trips succeed, with zero 401/invalid_token.

Released wrangler already authenticates the proxy via its own getAccessHeaders (env-first), which covers the paths this PR targeted. The miniflare connStr-carrier here is therefore redundant on current versions. Closing to avoid duplicate machinery. Thanks @petebacondarwin for the baseline work in #14234.

@krys-cf krys-cf closed this Jun 9, 2026
@github-project-automation github-project-automation Bot moved this from In Review to Done in workers-sdk Jun 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants