Cloudflare’s Programmable Gateway Turns Security Policy Into Customer Code, and the Launch Samples Show What That Code Must Get Right
Cloudflare’s new Forward action fails closed and skips WebSocket traffic. Running the launch post’s samples in workerd found a JWT role check that trusts unsigned tokens and an alert sample that...
On October 15, Cloudflare put a new Zero Trust Gateway action called Forward into public beta on all Zero Trust plans. When a policy matches an HTTP request that Gateway has already permitted, the request goes to a Worker the customer wrote. That Worker can change the request, change the response, or answer in place of the origin. The launch post, by AJ Gerstenhaber and Abe Carryl, presents this as a way to retire hand-built proxy pools, and it adds Workers that validate data loss prevention (DLP) matches. A third feature, custom functions that feed policy conditions, is promised “before the end of the year.”
Table Of Content
- What shipped, and what is still a promise
- What the docs say that the post does not
- The Forward action fails closed
- Traffic the Worker never sees, and bytes DLP never rescans
- The Workers Free allowance
- Running the four launch samples
- Sample 2: a role taken from an unsigned token
- A replacement that checks the signature
- Sample 3: an alert on a DLP match that the docs say cannot happen
- Sample 4: a GitHub token validator that treats a 401 as proof a token is dead
- Before you turn Forward on
- What I tested and what I did not
A policy engine that runs customer code moves part of the security decision into software the customer now has to get right. This piece reads the post against Cloudflare’s own documentation and the published @cloudflare/zt-types package, then runs the post’s four code samples in workerd 1.20261016.1, the open-source Workers runtime. I called them over Workers RPC, which the docs say is how Gateway reaches the Forward entrypoint. What I found:
- The Forward docs say failures “fail closed,” that a deleted Worker leaves matching requests returning HTTP 500, and that WebSocket and CONNECT traffic never reaches the Worker. The post does not mention any of that.
- The JWT role check accepted tokens with no valid signature, returned only a user’s first group, garbled a non-ASCII group name and threw on a token whose payload carried a URL with a query string.
- The sample that alerts on a DLP match matches no type in the published package, and the docs say a DLP match cannot select a Worker.
- The GitHub token validator ran. A made-up token drew HTTP 401 from
api.github.comand a veto. Going by GitHub’s docs, a working token from a different GitHub deployment would get the same answer. - A short replacement for the JWT sample that checks the signature rejected all eight bad tokens I tried and accepted both good ones.
What shipped, and what is still a promise
The post covers several entry points, and they are at different stages. The package is the only machine-readable contract for the shipped pieces, so it is the yardstick for the samples below.
| Piece | Status on October 16 | Where that comes from |
|---|---|---|
| Forward action | Public beta on all Zero Trust plans | The Forward docs: “available in public beta on all Zero Trust plans” |
| DLP validation Workers | The post says they are “available starting today”; the DLP docs say they appear in the entry editor “when this feature is available for your account” | Launch post and DLP validation docs |
| Custom functions as policy selectors | “Custom Functions will be available before the end of the year” | Launch post; the package has no types for them |
| Types package | @cloudflare/zt-types 2026.10.1, published October 15 at 08:47 UTC, about eight hours before the post |
npm registry |
What the docs say that the post does not
The post’s “Performance and reliability” section says “dispatching to a Worker does not introduce a network hop” and describes one failure setting, for custom functions: “you configure the fallback behavior per function.” The Forward page is blunter about the action that actually shipped.
The Forward action fails closed
The docs say: “Invocation errors, Worker errors, or failures in communication between Gateway and the Worker fail closed. Gateway does not bypass the Worker and forward the original request.” Gateway “resolves the selected Worker only when a Forward policy is saved,” so a deletion has a sharp edge: “Deleting the Worker does not remove or disable the policy. Matching requests then fail with HTTP 500 and do not reach the origin.” Identity errors default the same way. If context.identity() or context.devicePosture() throws and the Worker does not catch it, “Gateway returns HTTP 500,” and the Worker’s catch block decides whether to continue.
Fail closed is the safer default for a security control, and Cloudflare deserves credit for documenting it. It also makes the Worker an availability dependency for every matched user. A bad deploy, a deleted Worker or an uncaught exception shows up as an outage on the covered hosts, not as a gap in enforcement.
Traffic the Worker never sees, and bytes DLP never rescans
The middle column quotes the docs. The third column is my reading of them.
| Area | What the docs say | What it means for a control built on Forward |
|---|---|---|
| Protocols | “WebSocket traffic connects directly to the original upstream service and bypasses the selected Worker.” Proxy CONNECT tunnels do the same. | A justification page or audit trail built on Forward does not cover those connections. |
| Decryption | “HTTPS traffic must be decrypted.” No Worker runs for traffic that matches Do Not Inspect. | A TLS inspection exemption is also a Worker exemption, and the Worker plus every library it imports handles plaintext traffic. |
| Body inspection | “Gateway inspects the original body before delivering it to the Worker.” But “Worker invocation does not confirm that body inspection succeeded,” and a body Block “can interrupt an already-running Worker.” | A webhook call, an R2 write or an alert can fire for a request that inspection then blocks. |
| Worker edits | “Data the Worker adds to a permitted request does not receive another request-side DLP scan.” | Anything a Worker injects upstream is outside request-side DLP. |
| Selection | “Body inspection results, DLP matches, and response selectors cannot select a Worker.” | What DLP finds cannot trigger Forward. See sample 3. |
| Identity | “Use Gateway-provided identity for user-aware logic. Client-supplied identity headers are not an authoritative substitute.” | Reading a role from a request header goes against the docs. See sample 2. |
| Outbound calls | “A normal Workers fetch() is an independent subrequest.” “Do not assume a fetch() call uses the user’s Gateway policies or egress IP.” | Calls from the Worker to Slack, GitHub or a SIEM sit outside the user’s policies. |
The Workers Free allowance
The post notes that “Workers Free does have execution time limits and daily request limits, which is worth considering as you scale the volume of Gateway traffic you send to your Workers.” Cloudflare’s Workers limits page puts a number on it: “Accounts on the Workers Free plan have a daily request limit of 100,000 requests, resetting at midnight UTC.” A Worker over the limit either returns Error 1027 or is bypassed, depending on a fail mode the page ties to routes. A Forward policy picks its Worker by ID, and the Forward docs say the Worker “does not need a public route,” so it is unclear which mode applies, and neither the Forward docs nor the post say. Cloudflare’s template docs warn that “Page loads produce multiple HTTP requests.” At 50 requests per page load, an assumption of mine rather than a Cloudflare figure, 100,000 requests cover 2,000 page loads for the whole account.
Running the four launch samples
I copied each sample from the post, removed the TypeScript annotations and left the logic alone. The post prints the GitHub host as api.GitHub.com; I used the lowercase host GitHub documents, since host names are case-insensitive. Gateway itself needs a Zero Trust account, enrolled devices and TLS decryption, so the table says what the sample code does, not what a live Gateway does.
| Sample in the post | Result in workerd | Verdict |
|---|---|---|
1. Forward middleware: add X-Gateway-Client-IP from context.srcIp, then set a response header |
Both header edits applied; the origin stand-in saw 203.0.113.7 |
Runs. It takes the source address from Gateway’s context, as the docs advise. |
2. Custom function: a role from the JWT in the Authorization header |
Accepted tokens with no valid signature, returned the first group only, garbled a non-ASCII group name, threw on a query-string URL in a claim | Not safe as written |
| 3. “Real-time alerting on policy match” on a DLP profile | TypeError on a context shaped like the documented one; workerd refused the two-argument RPC call |
Does not match the shipped contract |
| 4. DLP validator that checks a GitHub token | A made-up token drew HTTP 401 from GitHub, and the sample returned a veto | Runs; three design questions |
Sample 2: a role taken from an unsigned token
The post’s custom function, for a feature that has not shipped yet, reads the Authorization header, decodes the middle segment of the token and returns the first group. Its example rule matches that value against finance_contractor with an Allow action. Here is the class as printed, minus the types:
export class GatewayCustomSelector extends WorkerEntrypoint {
async compute(ctx) {
const authHeader = ctx.request.headers.get("Authorization");
if (!authHeader) return "anonymous";
const token = authHeader.replace("Bearer ", "");
const payload = JSON.parse(atob(token.split(".")[1]));
return payload.groups?.[0] ?? "unknown";
}
}
It never checks a signature. Cloudflare’s context reference for the Forward action, which is the feature that shipped, says that “Client-supplied identity headers are not an authoritative substitute” for Gateway’s own identity. RFC 8725, the JWT best-current-practice document, goes further: “All cryptographic operations used in the JWT MUST be validated and the entire JWT MUST be rejected if any of them fail to validate.” This is what the sample returned:
| Authorization header | Sample returns |
|---|---|
| None | anonymous |
Well-formed token, groups finance_contractor, placeholder signature |
finance_contractor |
Header alg set to none, empty signature, same claims |
finance_contractor |
Signature AAAA, same claims |
finance_contractor |
Groups everyone, then finance_contractor |
everyone |
Group name Zürich-Finance |
Zürich-Finance |
A picture claim holding a URL with a query string |
Throws InvalidCharacterError |
Not a JWT (notajwt) |
Throws InvalidCharacterError |
The middle rows are the security finding: a token with alg set to none and no signature gets the same role as any other. The application behind the gateway should still verify its own token, so what an attacker gains depends on what the Allow rule unlocks, but the Gateway decision itself rests on input anyone can forge.
The other rows are correctness bugs. atob decodes the standard Base64 alphabet, while RFC 7515 defines base64url using “the URL- and filename-safe character set defined in Section 5 of RFC 4648,” which swaps + and / for - and _. I encoded all 2,097,152 three-byte ASCII sequences: in the base64url form of ASCII text, a - or _ can appear only when the third byte of a three-byte group is >, ?, ~ or DEL. Tokens with plain ASCII claims therefore usually decode, and a claim holding a URL with a query string can break the sample, as the row above shows. I did not measure non-ASCII payloads for crashes. They do cause a quieter fault: atob hands back UTF-8 bytes as Latin-1 characters, which is how Zürich-Finance became Zürich-Finance and would never match a rule written with the real name.
A replacement that checks the signature
Cloudflare’s own context page avoids the problem: await context.identity() returns Gateway’s identity object, and the docs example wraps it in a try and catch that picks the status code. If a rule really needs claims from an application token, the function has to verify the token. The code below is my test code, not Cloudflare’s. It handles RS256 only and takes the public key as an argument; production code would fetch and cache the issuer’s key set and pick a key by kid.
// verify.js: a signature-checking replacement for the post's JWT decode (RS256 only).
export function b64uToBytes(s) {
// JWT segments are base64url without padding (RFC 7515), so map to the standard alphabet and re-pad.
s = s.replaceAll("-", "+").replaceAll("_", "/");
s += "=".repeat((4 - (s.length % 4)) % 4);
return Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
}
export async function verifyJwt(token, jwk, { iss, aud, now = Date.now() / 1000 }) {
const parts = token.split(".");
if (parts.length !== 3) throw new Error("not a JWT");
const header = JSON.parse(new TextDecoder().decode(b64uToBytes(parts[0])));
// Never let the token choose the algorithm.
if (header.alg !== "RS256") throw new Error("unexpected alg");
const key = await crypto.subtle.importKey("jwk", jwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]);
const ok = await crypto.subtle.verify(
"RSASSA-PKCS1-v1_5",
key,
b64uToBytes(parts[2]),
new TextEncoder().encode(parts[0] + "." + parts[1]),
);
if (!ok) throw new Error("bad signature");
const claims = JSON.parse(new TextDecoder().decode(b64uToBytes(parts[1])));
if (claims.iss !== iss) throw new Error("wrong issuer");
const audOk = Array.isArray(claims.aud) ? claims.aud.includes(aud) : claims.aud === aud;
if (!audOk) throw new Error("wrong audience");
if (typeof claims.exp !== "number" || claims.exp <= now) throw new Error("expired");
return claims;
}
| Token | verifyJwt result |
|---|---|
Signed with the right key; groups everyone, Zürich-Finance, finance_contractor |
Returns all three groups, name intact |
Same, with a query-string URL in a claim so the payload contains _ |
Returns all three groups |
Header alg set to none |
Rejected: unexpected alg |
Header alg set to HS256 |
Rejected: unexpected alg |
Signature AAAA |
Rejected: bad signature |
| Signed by a different RSA key | Rejected: bad signature |
Payload swapped after signing (groups admins) |
Rejected: bad signature |
| Expired | Rejected: expired |
| Wrong audience | Rejected: wrong audience |
| Wrong issuer | Rejected: wrong issuer |
It follows RFC 8725’s rule that “Libraries MUST enable the caller to specify a supported set of algorithms and MUST NOT use any other algorithms”: the token does not get to choose the algorithm. It also returns every group, not the first.
Sample 3: an alert on a DLP match that the docs say cannot happen
The post’s example for “Real-time alerting on policy match” says: “A Worker bound to a Forward policy receives traffic whenever a data loss prevention (DLP) profile matches.” Its code is a default export with an invoke(ctx, env) method that reads ctx.identity.email, ctx.rule.name and ctx.request.url. Against the shipped contract:
| What the sample uses | What the docs and package say |
|---|---|
A default export with invoke(ctx, env) |
Gateway invokes “its GatewayMiddleware entrypoint, rather than a default fetch() handler,” as handle(request, context, next). |
ctx.identity.email |
identity is a method, called as await context.identity(). Read as a property it is a function, so .email is undefined. |
ctx.rule.name |
The context type has no rule field, only policyId. |
ctx.request.url |
The context type has no request field. The request is the first argument to handle. |
The type GatewayContext |
Not exported by @cloudflare/zt-types 2026.10.1. |
| A DLP profile match as the trigger | “Body inspection results, DLP matches, and response selectors cannot select a Worker,” and “Context does not include custom function results, DLP match details, or raw internal device records.” |
Run with a context shaped like the documented one, the sample throws TypeError: Cannot read properties of undefined (reading 'name'). Workerd also refused it as an RPC target, because a top-level handler function takes exactly one argument over RPC and a second one needs class syntax. Cloudflare’s own Slack template does something narrower that fits the documented entrypoint: it sends a background message “when a request to a selected application endpoint completes.” The post may be sketching what custom functions will do later. If so it should say so, because a team that copies the sample today gets a Worker that Forward would not invoke as written.
Sample 4: a GitHub token validator that treats a 401 as proof a token is dead
The sample decodes the excerpt Gateway passes, sends the matched string to https://api.github.com/user as a bearer token, and returns a score of 1.0 for a successful response, a veto for a 401 and 0 for anything else. The DLP docs define the outcomes: “A score of 0 leaves confidence unchanged,” and "veto" rejects the match. I ran it on a made-up string shaped like a classic personal access token, ghp_ plus 36 characters. GitHub answered 401 with “Bad credentials,” and the sample returned a veto. Three questions follow from the docs rather than from the run:
- A 401 says only that the token is not valid at that host. GitHub’s Enterprise Server documentation lists the same token prefixes (
ghp_,github_pat_,gho_,ghu_,ghs_), and its REST API guide puts the API athttp(s)://HOSTNAME/api/v3. A working token from an Enterprise Server instance is unknown toapi.github.com, so it looks like my made-up string and gets vetoed. The leak the profile exists to catch is then dropped. I had no Enterprise Server token to test, so this is reasoning from the docs. - The endpoint accepts some tokens and not others. GitHub’s users page lists GitHub App user access tokens and fine-grained personal access tokens as the supported fine-grained types for “Get the authenticated user.” It does not list installation access tokens, which the “Get a user using their ID” and “List users” entries on the same page do list. Such a token should land in the neutral 0 case rather than earn the boost. I could not test that without a live installation token.
- The validator uses the secret it found. It authenticates to a third party with a credential taken from employee traffic, and that credential may belong to a partner or customer. Whether a DLP system should do that is a decision for legal and security teams. The post does not raise it.
Two smaller points. The sample has no try and catch, so a GitHub outage becomes a Worker error, and the outcome then depends on the “default behavior for Worker errors or timeouts” that the DLP docs let you choose: “No fallback,” “Veto,” or “Score.” A Veto fallback would silently discard matches while GitHub is unreachable. And the package types describe start as the “Start index of the DLP match in” the excerpt, which is Base64 text, while the sample treats it as a byte offset into the decoded bytes. The docs I read do not state the unit, so test with multibyte text before relying on it.
Before you turn Forward on
- Treat the Worker as production code on the request path. It fails closed, so start with a test host and user group, catch errors around
identity()anddevicePosture()and choose the status code yourself, and remove policies before you delete a Worker. - Read identity from context, not headers. Use
await context.identity()and the posture list. Trusting a header is what the docs warn against. - If you must read a token, verify it. Check the signature with an algorithm you choose, then the issuer, audience and expiry, using real base64url decoding. Return every group.
- Do not make Forward your only control. WebSocket, CONNECT, Do Not Inspect traffic and protocol upgrades skip the Worker, and what the Worker adds skips request-side DLP.
- Put side effects after the decision, and keep captured data small. A Worker can start before body inspection finishes. Cloudflare’s templates say to “Capture only data your organization permits you to store,” while the post suggests writing a downloaded file “in its entirety” to R2 “as a permanent record.”
- Ask Cloudflare which fail mode applies at 100,000 daily requests, or run Forward on a paid plan.
- For DLP validators, do not read a 401 as proof that a token is dead. Cover every deployment that issues the token type, pick the error fallback on purpose and test the excerpt offsets with multibyte context.
Trusting what a caller supplies is a recurring theme. Our look at Cloudflare Traces covered a setting that decides whether the edge joins a caller’s trace, our tutorial on building and verifying JWTs walks through the alg=none forgery and an algorithm allowlist, and the WSO2 API Manager attacks show forged tokens used in the wild.
What I tested and what I did not
I read the launch post, the Forward, context, worker-template and DLP validation docs and the Workers limits page on October 16, 2026, and downloaded @cloudflare/zt-types 2026.10.1 from npm to read its type definitions. I ran the samples in workerd 1.20261016.1 on Windows, using the npm release of the standalone binary, with one test worker calling the post’s classes over service bindings. The GitHub call went to the live API with a made-up token. The ASCII check encoded every three-byte ASCII sequence. The replacement function ran with an RSA-2048 key pair generated inside workerd.
I did not use a Cloudflare account, so I did not run Gateway, a Forward policy, TLS decryption, a DLP profile or a Free-plan limit. Custom functions have not shipped, so the JWT sample was tested as plain code only. I had no Enterprise Server or GitHub App installation token, so those two GitHub points rest on GitHub’s documentation. Whether Gateway hands a Worker the same kind of request and response objects as my stand-ins is untested; the first sample’s header edits worked in my setup. The validator ran against github.com only.








No Comment! Be the first one.