workerd in front of a Rust API capability bindings, the SSRF-safe default, compatibility dates, and a Nix check
The config file, not the code, decides what a Worker can reach. Bind the backend, pin the runtime, and let the build run the tests.
The problem
A team runs an HTTP API written in Rust with axum, a Rust web framework. /login checks a password and issues a JSON Web Token (JWT), a signed string that proves who the caller is. /me requires that token in an Authorization: Bearer <token> header, which makes it a bearer token, and returns the claims inside it.
The team wants a cheap layer in front of this API, called the origin API below. The layer should answer health checks itself, turn away requests to protected paths that carry no bearer token before they cost the origin anything, and stamp every forwarded request with a request ID so log lines can be correlated.
The layer is a Cloudflare Worker, a small JavaScript module that handles HTTP requests on Cloudflare's edge, its network of servers close to users. The team wants to run exactly that code on a laptop and in continuous integration (CI), with no cloud account.
The first attempt calls fetch("http://127.0.0.1:8081/...") from the Worker and fails with nothing but "internal error". The code looks correct; the cause is a deliberate safety default in the runtime.
The idea, in plain words
workerd is the open-source runtime that runs Workers code. It reads a configuration file that lists named services, and that file, not the code, decides what each Worker can reach. A Worker has access to nothing privileged until the file binds it. That rule explains the "internal error" and also lets a test swap the real API for a mock. Tests run with workerd test, and a Nix flake (a project file with a standard layout for packages and checks) can run that command as a check, so a failing test fails the build.
Tested with: workerd 2026-09-25 and 2026-10-06 (from nixpkgs wrangler), Nix 2.35.2 on aarch64-darwin, and an origin built with axum 0.8.9 and jsonwebtoken 11.1.0, all on 127.0.0.1.
The workerd config file: services, sockets, and one Worker
workerd's README calls it "a JavaScript / Wasm server runtime based on the same code that powers Cloudflare Workers". It is configured with a file in Cap'n Proto text format (a schema language with a readable text syntax), and workerd serve config.capnp starts it. A service is a named thing that handles requests: a Worker, an external HTTP server, or a network policy. A socket is an address workerd listens on, wired to one service. Services stay private until a socket or a binding points at them.
using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
services = [
(name = "edge", worker = .edgeWorker),
(name = "origin", external = (address = "127.0.0.1:8081", http = ())),
(name = "no-network", network = (allow = [])),
],
sockets = [
(name = "http", address = "127.0.0.1:8787", http = (), service = "edge"),
],
);
const edgeWorker :Workerd.Worker = (
modules = [(name = "worker", esModule = embed "worker.js")],
compatibilityDate = "2026-09-25",
bindings = [(name = "API", service = "origin")],
globalOutbound = "no-network",
);
edge is the Worker described in edgeWorker. origin is an external server: workerd sends plain HTTP to 127.0.0.1:8081, where the Rust API listens. no-network is a network service with an empty allow list, so nothing routed through it can connect anywhere. The socket on 127.0.0.1:8787 hands every request to edge. The Worker embeds worker.js as an ES module (the standard JavaScript module format), receives the origin as a binding named API, and has its global fetch() pointed at no-network.
// Edge Worker: answers health checks itself, rejects requests to
// protected paths that carry no Bearer token, and forwards the rest
// to the origin API through the API service binding.
const PROTECTED = ["/me"];
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/healthz") {
return new Response("edge ok\n", { status: 200 });
}
const auth = request.headers.get("authorization") ?? "";
if (PROTECTED.includes(url.pathname) && !/^Bearer [A-Za-z0-9._~+\/-]+=*$/.test(auth)) {
return new Response(JSON.stringify({ error: "missing bearer token (edge)" }) + "\n", {
status: 401,
headers: {
"content-type": "application/json",
"www-authenticate": 'Bearer realm="notes-api"',
},
});
}
const headers = new Headers(request.headers);
headers.set("x-request-id", crypto.randomUUID());
return env.API.fetch(new Request(request, { headers }));
},
};
The Worker has three jobs. A request for /healthz gets "edge ok" and goes no further. For /me, a regular expression checks that the header looks like Bearer followed by token characters. If it does not, the edge answers 401 with a WWW-Authenticate challenge, which RFC 6750 says a resource server must send when the request carries no usable credentials. Every other request gets a copy of its headers plus a fresh x-request-id from crypto.randomUUID(), then goes to env.API.fetch(). In an ES module Worker, bindings arrive on the env object, so env.API is the origin service from the config.
With the Rust API on 8081 and workerd serving this config:
$ curl http://127.0.0.1:8787/healthz
HTTP/1.1 200 OK
Content-Length: 8
Content-Type: text/plain;charset=UTF-8
edge ok
$ curl http://127.0.0.1:8787/me
HTTP/1.1 401 Unauthorized
Content-Length: 40
Content-Type: application/json
WWW-Authenticate: Bearer realm="notes-api"
{"error":"missing bearer token (edge)"}
$ curl -H 'authorization: Basic YWRhOnB3' http://127.0.0.1:8787/me
HTTP/1.1 401 Unauthorized
Content-Length: 40
Content-Type: application/json
WWW-Authenticate: Bearer realm="notes-api"
{"error":"missing bearer token (edge)"}
The edge answered all three itself; its 401 is recognizable by realm="notes-api" and "(edge)" in the body, and a Basic credential fails the Bearer pattern. To prove that none of these touched the origin, the lab stopped the Rust API and repeated the first two requests: /healthz still returned 200 edge ok and /me still returned the edge's 401.
Next the lab logged in through the edge (the token had 3 dot-separated parts) and called /me twice, the second time with the signature's last character changed:
HTTP/1.1 200 OK
Content-Length: 99
Content-Type: application/json
{"sub":"ada","iss":"https://auth.example.test","aud":"notes-api","iat":1792431789,"exp":1792432689}
HTTP/1.1 401 Unauthorized
Content-Length: 43
Content-Type: application/json
WWW-Authenticate: Bearer
{"error":"invalid token: InvalidSignature"}
The valid token reached the Rust API and returned ada's claims. The tampered token also passed the edge, because its shape is still a bearer token, and the origin rejected it with a bare WWW-Authenticate: Bearer with no realm parameter. Comparing the two 401 responses tells you which layer answered.
Diagnostic: the edge check is not authentication.
- Symptom: a forged token gets past the edge and is refused only by the origin.
- Cause: the edge regular expression checks the shape of the header and holds no signing key, so it cannot verify a signature.
- Fix: keep full validation in the origin; the edge only spares it obviously empty requests.
Diagnostic: workerd dies at startup with a bind error.
- Symptom: the first serve attempt, on port 8080, ended with
*** Fatal uncaught kj::Exception: rust/cxx/kj-rs-io/ffi.rs:197: failed: bind(): Address already in use (os error 48). - Cause: another process already listened on 8080.
- Fix: pick a free port; the lab moved to 8787.
Capability bindings and the SSRF-safe default
Server-side request forgery (SSRF) tricks a server into sending requests where the caller should not reach, such as addresses inside its own network. workerd answers it with capability bindings. A binding is a named handle, declared in the config, that gives a Worker a JavaScript object pointing at one specific resource. The config schema states the default plainly: "a Worker has access to no privileged resources at all", so changing the config alone controls what a Worker can connect to.
A Worker has two outbound paths. A call through a binding to an external server goes to that server whatever hostname the URL contains, which is how a reverse proxy, a server that forwards requests to a backend, reaches that backend. A call to global fetch() goes to the service named "internet", or to whatever globalOutbound names. If the config defines no "internet" service, workerd supplies one whose network allow list is ["public"], which admits publicly routable addresses only, so private and local addresses such as 127.0.0.1 are refused.
// Tries to reach the origin with global fetch() instead of a binding.
export default {
async fetch() {
try {
const res = await fetch("http://127.0.0.1:8081/health");
return new Response(`global fetch got ${res.status}\n`);
} catch (e) {
return new Response(`global fetch failed: ${e.message}\n`, { status: 502 });
}
},
};
This is the team's first attempt in miniature. Its config (demo/ssrf.capnp, port 8090) defines no "internet" service and no bindings, so it gets the default, and the catch block returns the error message in a 502. Nothing in the code names a network policy; the policy comes entirely from the config the Worker runs under, which is what the next two outputs show:
$ curl http://127.0.0.1:8090/
HTTP/1.1 502 Bad Gateway
Content-Length: 74
Content-Type: text/plain;charset=UTF-8
global fetch failed: internal error; reference = cdrd2thob10oupm04tidr42v
workerd/jsg/util.c++:390: error: e = rust/cxx/kj-rs-io/async-io.c++:238: failed: connect() blocked by restrictPeers()
stack: 103ca603b 103cab070 103d3da2c ... sentryErrorContext = jsgInternalError; wdErrId = cdrd2thob10oupm04tidr42v
The client sees only a reference ID. The server log names the allow-list check that refused the connection, restrictPeers(), and its wdErrId equals that reference.
Diagnostic: an opaque "internal error" from global fetch().
- Symptom: a Worker calling a local or private address fails with "internal error; reference = ...".
- Cause: global
fetch()goes to the default "internet" service, which allows public addresses only. - Fix: find the reference ID in the server log to confirm the block, then bind the backend as an external server and call
env.API.fetch().
The edge config also sets globalOutbound = "no-network", so the API binding is the edge Worker's only way out. One limit from the README: workerd "is not a hardened sandbox", so possibly-malicious code belongs inside something like a virtual machine.
Compatibility dates
A compatibility date is a field in a Worker's config that opts the Worker into runtime changes made up to that date. Cloudflare's docs promise that old dates are supported forever. It exists so that a runtime upgrade does not change behavior under working code; otherwise any release could break a deployed Worker. workerd's version number is itself a date, documented as the newest compatibility date that version supports, and a config can always name an older one.
Two broken copies of the demo config, one dated 2026-12-01 and one with no date:
-- future-date.capnp
service main: This Worker requires compatibility date "2026-12-01", but the newest date supported by this server binary is "2026-10-02".
exit=1
-- no-date.capnp
service main: Worker must specify compatibilityDate.
exit=1
Both stopped with exit status 1 before listening on any port. Note that this binary printed workerd 2026-09-25 for --version, yet its error message says it accepts dates up to 2026-10-02. Treat the version string as approximate and the error text as the authority.
Diagnostic: a service that never starts.
- Symptom: workerd exits at startup with a message about a compatibility date.
- Cause: the date is missing, or it is newer than the binary supports.
- Fix: set a date in every Worker, no newer than the binary in any environment, and pin the binary (next section). Beware the opposite case on Cloudflare: an API upload without a date defaults to 2021-11-02 and silently selects old behavior.
Tests and the Nix flake check
workerd test config.capnp runs every exported object that has a test() handler. A test config needs no sockets, and workerd's own sample redefines the "internet" service with an empty allow list so tests cannot touch the network. The test config below embeds the same worker.js as the serve config and changes only where API points. First, the stand-in origin.
// Stand-in for the origin API during tests. It reports what it received
// so a test can check what the edge forwarded.
export default {
async fetch(request) {
return Response.json({
path: new URL(request.url).pathname,
requestId: request.headers.get("x-request-id"),
auth: request.headers.get("authorization"),
});
},
};
The mock echoes what it received, so a test can assert on what the edge forwarded without a Rust API or a network. Response.json() builds a JSON response, and each field reads one thing the edge is responsible for: the path from the URL, the request ID header, and the authorization header. A test that compares those fields can tell a forwarded request from one the edge refused.
using Workerd = import "/workerd/workerd.capnp";
const tests :Workerd.Config = (
services = [
(name = "tests", worker = .testWorker),
(name = "edge", worker = .edgeUnderTest),
(name = "mock-origin", worker = .mockOrigin),
(name = "internet", network = (allow = [])),
],
);
const testWorker :Workerd.Worker = (
modules = [(name = "tests", esModule = embed "tests.js")],
compatibilityDate = "2026-09-25",
bindings = [(name = "EDGE", service = "edge")],
);
# Same worker.js as production; only the API binding changes.
const edgeUnderTest :Workerd.Worker = (
modules = [(name = "worker", esModule = embed "worker.js")],
compatibilityDate = "2026-09-25",
bindings = [(name = "API", service = "mock-origin")],
);
const mockOrigin :Workerd.Worker = (
modules = [(name = "mock", esModule = embed "mock-origin.js")],
compatibilityDate = "2026-09-25",
);
There are no sockets. The tests Worker reaches the edge through its EDGE binding; the edge is the real worker.js with API pointed at mock-origin; and the redefined internet blocks all outbound connections. Because the Worker knows only binding names, the test substitutes a backend without touching the code under test, and nothing besides workerd itself has to be running.
// Run with: workerd test test.capnp
function assert(cond, msg) {
if (!cond) throw new Error(msg);
}
export let healthIsAnsweredAtTheEdge = {
async test(ctrl, env) {
const res = await env.EDGE.fetch("http://edge.test/healthz");
assert(res.status === 200, `healthz status ${res.status}`);
assert((await res.text()) === "edge ok\n", "healthz body");
},
};
export let missingBearerNeverReachesOrigin = {
async test(ctrl, env) {
const res = await env.EDGE.fetch("http://edge.test/me");
assert(res.status === 401, `expected 401, got ${res.status}`);
assert(res.headers.get("www-authenticate") === 'Bearer realm="notes-api"', "challenge header");
const body = await res.json();
assert(body.error === "missing bearer token (edge)", "401 must come from the edge, not the origin");
},
};
export let bearerIsForwardedWithRequestId = {
async test(ctrl, env) {
const res = await env.EDGE.fetch("http://edge.test/me", {
headers: { authorization: "Bearer abc.def.ghi" },
});
assert(res.status === 200, `expected 200, got ${res.status}`);
const seen = await res.json();
assert(seen.path === "/me", "origin saw the path");
assert(seen.auth === "Bearer abc.def.ghi", "origin saw the token unchanged");
assert(/^[0-9a-f-]{36}$/.test(seen.requestId ?? ""), "origin saw an x-request-id");
},
};
Each exported object with a test() method is one test, and the three cover the three jobs from the problem statement. The body check in the second test proves the 401 came from the edge. The token abc.def.ghi is never validated, because the mock does no validation. The made-up hostname edge.test works because the binding, not DNS, decides where each request goes.
The green run, trimmed to the result lines:
workerd/server/server.c++:7778: debug: [ PASS ] tests:bearerIsForwardedWithRequestId (10.395ms)
workerd/server/server.c++:7778: debug: [ PASS ] tests:healthIsAnsweredAtTheEdge (248μs)
workerd/server/server.c++:7778: debug: [ PASS ] tests:missingBearerNeverReachesOrigin (433μs)
exit=0
Then the lab disabled the bearer check in a copy of the Worker by changing the condition to if (false && ...):
workerd/io/worker.c++:2585: info: uncaught exception; source = Uncaught (in promise); stack = Error: expected 401, got 200
workerd/server/server.c++:7778: debug: [ FAIL ] tests:missingBearerNeverReachesOrigin (1.564ms)
Tests failed!
exit=1
Only the test guarding the rejection failed, and exit status 1 lets CI fail the run.
Now the flake. The pinned nixpkgs has no standalone workerd package (nix eval nixpkgs#workerd.version fails), but its wrangler package, Cloudflare's command-line tool, contains the workerd binary, so the flake extracts it.
{
description = "Edge Worker on workerd, with workerd test as a flake check";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
outputs = { self, nixpkgs }:
let
systems = [ "aarch64-darwin" "x86_64-linux" "aarch64-linux" ];
forAll = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
# nixpkgs ships the workerd binary inside its wrangler package rather
# than as a package of its own; expose the newest bundled copy.
workerdFrom = pkgs: pkgs.runCommand "workerd-from-wrangler" { } ''
bin=$(find ${pkgs.wrangler}/lib/node_modules/.pnpm \
-path '*@cloudflare+workerd-*/bin/workerd' -type f | sort -V | tail -n 1)
test -n "$bin" || { echo "no workerd binary inside wrangler" >&2; exit 1; }
mkdir -p $out/bin
ln -s "$bin" $out/bin/workerd
'';
in {
packages = forAll (pkgs: {
workerd = workerdFrom pkgs;
default = pkgs.writeShellApplication {
name = "edge-serve";
runtimeInputs = [ (workerdFrom pkgs) ];
text = ''exec workerd serve ${./edge}/config.capnp "$@"'';
};
});
checks = forAll (pkgs: {
worker-test = pkgs.runCommand "edge-worker-test" {
nativeBuildInputs = [ (workerdFrom pkgs) ];
} ''
cd ${./edge}
workerd test test.capnp
touch $out
'';
});
};
}
workerdFrom is a derivation (a Nix build step) that finds the newest workerd binary inside wrangler by version sort and links it into bin/, failing if none exists. checks.worker-test uses runCommand, which fails the build whenever its shell commands fail, so a failing workerd test fails the check and touch $out creates the output only when the tests pass. packages.default uses writeShellApplication. The lab built it with nix build .#default and read the generated script:
#!/nix/store/f0b9dbw2gl596cm03pvdsss72381qhlp-bash-5.3p15/bin/bash
set -o errexit
set -o nounset
set -o pipefail
export PATH="/nix/store/8kzkc3na2v1m0sdl30fx3rwc20rcxv0l-workerd-from-wrangler/bin:$PATH"
exec workerd serve /nix/store/1wir2ibz74xavc7wlgh7jwb5ps7iysr0-edge/config.capnp "$@"
The helper added strict shell options and put workerd on PATH, and the config path points into the Nix store, so the script serves exactly the tested files. Against the live origin it answered /healthz with edge ok, gave the edge's 401 for a bare /me, and returned ada's claims for a valid token.
nix flake check -L (where -L prints build logs) ran the tests inside the build:
running 2 flake checks...
edge-worker-test> workerd/server/server.c++:8215: debug: [ PASS ] tests:bearerIsForwardedWithRequestId (2.387ms)
edge-worker-test> workerd/server/server.c++:8215: debug: [ PASS ] tests:healthIsAnsweredAtTheEdge (131μs)
edge-worker-test> workerd/server/server.c++:8215: debug: [ PASS ] tests:missingBearerNeverReachesOrigin (143μs)
✅ checks.aarch64-darwin.worker-test
warning: The check omitted these incompatible systems: aarch64-linux, x86_64-linux
Use '--all-systems' to check all.
exit=0
With the bearer check disabled again, the same command ended like this:
edge-worker-test> workerd/server/server.c++:8215: debug: [ FAIL ] tests:missingBearerNeverReachesOrigin (344μs)
edge-worker-test> Tests failed!
❌ checks.aarch64-darwin.worker-test
error: Cannot build '/nix/store/v96ixb87gnmgw73w70vzq0szzxb9jzi4-edge-worker-test.drv'.
Reason: builder failed with exit code 1.
exit=1
Diagnostic: a green check that proves less than it seems.
- Symptom:
nix flake checkpasses, yet the warning says aarch64-linux and x86_64-linux were omitted. - Cause: the command builds checks only for the current system by default, and this run was on aarch64-darwin.
- Fix: use
--all-systemsor a Linux runner. The lab did not confirm the Linuxwranglerlayout matches the search path.
The flake's pinned nixpkgs (f20cd90, 2026-10-16) carried workerd 2026-10-06, newer than the 2026-09-25 binary used for the serve demo. Commit flake.lock so every machine runs the same one.
Putting it together
A request to the edge on 127.0.0.1:8787 takes one of three paths. /healthz is answered by the edge. A /me request without a bearer-shaped header gets the edge's 401. Everything else gets an x-request-id and goes through the API binding to the origin, which validates the signature and sends its own 401 for a bad token. (The request ID was verified against the mock, not in live origin logs.)
With the Rust API stopped, /healthz still returned 200 edge ok, and a bearer request got no HTTP response at all:
$ curl -H 'authorization: Bearer abc.def.ghi' http://127.0.0.1:8787/me
curl: (52) Empty reply from server
The default log was silent; workerd serve --verbose showed the cause: disconnected: connect(): Connection refused (os error 61) followed by Uncaught (in promise); stack = Error: Network connection lost. The Worker never catches the failed env.API.fetch(), so workerd drops the connection.
Diagnostic: green health, dead origin.
- Symptom: the load balancer sees
/healthzas healthy while clients get empty replies. - Cause:
/healthzis answered at the edge and never consults the origin, and the uncaught binding error closes the connection. - Fix: probe the origin separately, and catch the binding error. The lab tested this variant with the origin stopped, and it returned
HTTP/1.1 502 Bad Gatewaywith the bodyorigin unavailable:
try {
return await env.API.fetch(new Request(request, { headers }));
} catch (e) {
return new Response("origin unavailable\n", { status: 502 });
}
The await matters: without it the rejected promise escapes the try block and the error is still uncaught. The caller then gets a gateway error that monitoring can count.
To run it, start the Rust API on 127.0.0.1:8081, then nix run .#default, and gate merges on nix flake check. Nothing here used a Cloudflare account; deploying to Cloudflare goes through wrangler and its own configuration, which the lab did not do.
Check yourself
fetch("http://127.0.0.1:8081/health") and the client sees "internal error; reference = ...". Where is the real reason, and what is the fix?wdErrId matches: connect() blocked by restrictPeers(), since the default "internet" service allows only public addresses. Bind the backend as an external server and call env.NAME.fetch().realm parameter, while the edge's carries realm="notes-api" and "(edge)" in the body.--version.test.capnp embed the same worker.js as the serve config, and what does it change?API binding changes (to a mock), and "internet" gets an empty allow list./healthz and an authenticated /me return with the Worker as first written, and why?/healthz still returns 200 because the edge answers it alone; /me gets an empty reply because the failed binding call is uncaught. An awaited try/catch turns it into a 502.Sources
- workerd README: https://github.com/cloudflare/workerd
- workerd configuration schema (
workerd.capnp, tag v1.20260925.1): https://github.com/cloudflare/workerd/blob/main/src/workerd/server/workerd.capnp - Cloudflare docs, Service bindings: https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/
- Cloudflare docs, Compatibility dates: https://developers.cloudflare.com/workers/configuration/compatibility-dates/
- workerd sample, unit-tests: https://github.com/cloudflare/workerd/tree/main/samples/unit-tests
- RFC 6750, The OAuth 2.0 Authorization Framework: Bearer Token Usage, section 3: https://www.rfc-editor.org/rfc/rfc6750#section-3
- Nix manual,
nix flake check: https://nix.dev/manual/nix/latest/command-ref/new-cli/nix3-flake-check.html - Nixpkgs manual,
runCommand: https://nixos.org/manual/nixpkgs/stable/#trivial-builder-runCommand - Nixpkgs manual,
writeShellApplication: https://nixos.org/manual/nixpkgs/stable/#trivial-builder-writeShellApplication