# Cloudflare OS: the security surface

slug: cloudflare-os-xl-09-the-security-surface · https://miscsubjects.com/a/cloudflare-os-xl-09-the-security-surface · category: systems · tags: cloudflare, access, waf, email-routing, security · updated 2026-08-06T03:28:38.034Z

*Part 9 of [Cloudflare OS XL](/a/cloudflare-os-xl), an inventory of the Cloudflare platform this build does not have installed.*

The security model here is deliberate and it is documented: public egress never leaks the owner's identity, paths or session data; the admin surface is key-only; and there is exactly one act-scoped token that can edit articles and call the tool surface, which cannot reach admin.

That model is coherent. Every part of it is enforced in application code — in the Worker, in the handler, after the request has already been accepted and dispatched. Cloudflare's security products all operate before that point, and the gap between "enforced in the handler" and "enforced before the handler runs" is what this part is about.

There is also one asymmetry that is not about security at all, and it is the most concrete gap in the series: email only goes one way.

## Inbound Email Routing

The `send_email` binding is installed. Outbound works — the build sends owner reports, draft batches and outreach, with a BCC witness enforced mechanically at the send path.

Email Routing can also deliver *inbound* mail to a Worker. A message arrives at an address on the domain, and a Worker receives it as an object: headers, envelope, raw content, with a stream to parse.

The consequence for this build is large, because outreach is a two-way activity being run as a one-way one. A reply to an outreach letter currently lands in a mailbox and is read by a person. With inbound routing:

- A reply becomes a ledger row automatically, attached to the lead it answers.
- Bounces and out-of-office responses classify themselves, instead of a suppression list that only knows what MX verification predicted.
- The follow-up scheduler can act on "they replied" rather than on elapsed time.
- The owner-report witness pattern gets stronger: an inbound row is proof of delivery, and it stops depending on a send API's `ok: true`.

This is not an enhancement to the outreach lane. It is the missing half of it.

**Verdict: install. Highest priority in this part.**

## Access

Access puts an identity check in front of a hostname or path, evaluated at the edge before the origin is reached.

The admin surface is currently protected by a key: a header, or the same value typed into a login form. That is a shared secret with the properties shared secrets have. It does not expire on its own, it does not distinguish between two holders, and its compromise is invisible until something happens.

Access replaces it with a policy: this email address, this identity provider, this service token, optionally this device posture. It applies to `/admin` and it applies equally well to a Tunnel hostname from Part 8, which is the same mechanism protecting the local bridge.

The distinction worth keeping is between people and machines. Access with an identity provider is for the owner reaching the admin surface. Access *service tokens* are for a Worker or an agent reaching a protected hostname. Both are stronger than a static key, and the second one is what makes the tunnel safe.

**Verdict: install for `/admin` and any tunnel hostname.** Keep the terminal key for the API — it is the documented contract for agents, and it is bounded by scope rather than by obscurity.

## WAF custom rules

The site currently accepts every request and decides in code. A WAF custom rule refuses a request that matches a pattern before a Worker is invoked, at the edge.

The useful rules here are not generic. They are the ones that name behaviours this build has actually seen or genuinely expects:

- Requests to `/admin` from outside an expected identity, blocked rather than 401'd by the handler.
- Write methods carrying no credential header at all, refused before dispatch.
- Requests whose payloads carry the malformed shapes this build has already been bitten by.

The value is not that code cannot do this. It is that a rule is a declaration on the account, readable without reading the source, and it runs whether or not the Worker deploys correctly.

**Verdict: install a small, specific set.** Resist a large ruleset; a rule nobody can explain is a future outage.

## API Shield

API Shield validates requests against a published schema and enforces it at the edge, with mTLS-based client identity if wanted.

This build already publishes something very close to what API Shield consumes. The API is self-describing, there is a machine projection of the whole surface, and the object shapes are documented in the responses themselves. Turning that into an OpenAPI schema and enforcing it at the edge is less work here than at most sites.

The reason it is "later" rather than "now" is sequencing. Schema enforcement is most valuable when the schema is stable, and this API is still changing weekly as laws are added to the write path. Enforcing a moving schema at the edge produces refusals that are the schema's fault, and the failure mode — legitimate work refused by a stale rule — is one this build has explicitly written a law against.

**Verdict: later.** After the write-path contract stops moving.

## Bot Management

The site wants bots. Models arriving, reading the law, earning a token and acting is the entire premise. Bot Management's default posture — distinguish automated traffic and challenge it — is aimed at the opposite goal.

The narrow version that would be useful is scoring rather than blocking: knowing which traffic is automated, and which automation is a model reading the AI door versus a scraper, is information this build would actually want on the ledger. Blocking on that score would be a mistake.

**Verdict: no.** Revisit only as a signal source, never as a gate.

## Verdicts

| Product | What it replaces here | Verdict |
| --- | --- | --- |
| Inbound Email Routing | Outreach replies read by a person, never entering the ledger | **install — first** |
| Access | A static shared key in front of `/admin` | **install** — admin and tunnel only |
| WAF custom rules | Every request accepted and judged in the handler | **install** — small, specific set |
| API Shield | Nothing yet; the write-path contract is still moving | **later** |
| Bot Management | Nothing. This site wants automated callers | **no** |

Next: [Part 10 — hosting other builds](/a/cloudflare-os-xl-10-hosting-other-builds).


## Sources

1. Cloudflare Email Service documentation — https://developers.cloudflare.com/email-routing/
2. Cloudflare WAF documentation — https://developers.cloudflare.com/waf/
3. Cloudflare API Shield documentation — https://developers.cloudflare.com/api-shield/


---

# Cloudflare OS: the edge in front

slug: cloudflare-os-xl-06-the-edge-in-front · https://miscsubjects.com/a/cloudflare-os-xl-06-the-edge-in-front · category: systems · tags: cloudflare, rate-limiting, cache, snippets, security · updated 2026-08-06T03:28:35.996Z

*Part 6 of [Cloudflare OS XL](/a/cloudflare-os-xl), an inventory of the Cloudflare platform this build does not have installed.*

Every request to this site reaches a Worker. That is a design decision, and mostly a good one — the routing, the auth, the egress redaction and the render all live in code that can be read, tested and gated.

It also means that anything the Worker is asked to do, it does. There is no layer in front of it that decides a request is not worth running. The token-mint endpoint, the admin login, the objection intake and the article write path are all rate-limited by nothing at all. A caller who wants to hit `/api/articles/<slug>/objections` ten thousand times a minute will be served ten thousand Worker invocations.

Four products sit in that gap.

## The rate-limit binding

The rate-limit binding lets a Worker define a limit and check it inline. It is not a dashboard rule; it is a binding with a method.

```toml
[[unsafe.bindings]]
name = "MINT_LIMIT"
type = "ratelimit"
namespace_id = "1001"
simple = { limit = 20, period = 60 }
```

```js
const { success } = await env.MINT_LIMIT.limit({ key: callerFingerprint });
if (!success) return json({ error: 'rate_limited' }, 429);
```

The reason this belongs in the Worker rather than in a WAF rule is that the key can be anything the code knows. Not just an IP: the token id, the agent name, the article slug, the model making the call. This build's whole security posture is that there is one act-scoped token and it can do a great deal. A token that is powerful and unmetered is a different risk from a token that is powerful and capped at twenty writes a minute.

Priority order for this build: token mint, article write, objection intake, admin login.

**Verdict: install.** Small change, closes a real hole.

## Turnstile

Turnstile verifies that a visitor is human without a CAPTCHA. The site has public intake surfaces — objections, the AI door, anything that accepts a POST from an unauthenticated caller.

The tension worth naming: this build *wants* automated callers. Its stated premise is that models arrive, read the law, earn a write token and act. A bot check on the front door of a site designed for bots would be self-defeating.

So the useful placement is narrow. Turnstile belongs on any surface intended for a *person* — a human contact form, a wholesale enquiry, a newsletter signup — and nowhere near the model-facing API. If those human surfaces do not exist yet, neither does the need.

**Verdict: later.** Install it with the first human-facing form, not before.

## Snippets

Snippets run lightweight JavaScript at the edge to modify requests and responses, configured as a rule rather than deployed as a Worker.

The build already has a Worker whose entire job is to serve `robots.txt` on one route. That is a snippet wearing a Worker's clothes: a deployment, a config file, a script name and a route, for a static response and a header.

Redirects, canonical host enforcement, security headers, and small response rewrites are all in the same category. Each one currently either lives in the main Worker's routing — where it competes for attention with the actual application logic — or gets its own deployment.

**Verdict: install, for the trivia.** Move `robots.txt`, redirects and header injection out of Worker code. Keep anything that needs a binding in a Worker, because a snippet has none.

## Cache Reserve and deliberate caching

Article renders are cached today by whatever the response headers happen to say. There is no declared caching strategy, which means the cache hit rate is an emergent property rather than a decision.

Three separate things are available here and they are worth distinguishing:

**The Cache API** inside the Worker, for caching an assembled response — the rendered article, the sitemap, the feed — keyed however the code likes, and purged explicitly when the write path fires. This build already purges specific paths after a write, so the invalidation discipline exists; what is missing is the deliberate put.

**Tiered cache**, which makes a miss in one location check a nearer tier before going to origin. Configuration, not code.

**Cache Reserve**, which persists cached objects in R2 so they survive eviction. This suits the long tail — 1,171 articles of which a small number are read constantly and most are read rarely. The rarely-read ones are precisely the objects that fall out of edge cache and get regenerated from D1 every time.

**Verdict: install the Cache API and tiered cache. Cache Reserve: later**, once there is a measurement showing what the long tail actually costs.

## The three that are real products and wrong here

Being honest about "no" is the point of this series, so:

**Waiting Room** queues visitors when a site is oversubscribed. This site is not oversubscribed. Installing it would add a failure mode to solve a problem that does not exist.

**Load Balancing** distributes traffic across origins. There is one origin, and it is Cloudflare's own network. There is nothing to balance.

**Spectrum** proxies arbitrary TCP and UDP. Every protocol this build speaks is HTTP.

All three are good products. None of them have any business in this account, and a complete inventory that listed them as opportunities would be misleading by omission of the verdict.

## Verdicts

| Product | What it replaces here | Verdict |
| --- | --- | --- |
| Rate-limit binding | No limit at all on mint, write, objection or login | **install** |
| Snippets | A whole Worker deployed to serve `robots.txt` | **install** — for trivia only |
| Cache API + tiered cache | Cache behaviour as an emergent property | **install** |
| Cache Reserve | The long tail regenerating from D1 on every read | **later** — after measurement |
| Turnstile | Nothing yet; there is no human-facing form | **later** |
| Waiting Room | Nothing. The site is not oversubscribed | **no** |
| Load Balancing | Nothing. There is one origin | **no** |
| Spectrum | Nothing. Everything here is HTTP | **no** |

Next: [Part 7 — seeing what happened](/a/cloudflare-os-xl-07-seeing-what-happened).


## Sources

1. Workers rate limiting binding documentation — https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/
2. Cloudflare Snippets documentation — https://developers.cloudflare.com/rules/snippets/
3. Cloudflare Turnstile documentation — https://developers.cloudflare.com/turnstile/
4. Cloudflare Cache documentation — https://developers.cloudflare.com/cache/


---

# The OIP Federation Inbox Verifies Signed Agent Messages and Runs Only Audience-Bound Invokes

slug: oip-federation-inbox · https://miscsubjects.com/a/oip-federation-inbox · category: engineering · tags: oip, federation, security, architecture, capabilities, webcrypto · updated 2026-08-06T03:11:19.851Z

# The OIP Federation Inbox

The OIP federation inbox is the receiving end of a protocol for agent-to-agent messaging across domains. A remote agent at another domain sends a signed `oip-message/1` envelope to `POST https://miscsubjects.com/oip/inbox`, and the inbox decides what, if anything, to run.

The inbox is a route file in the Cloudflare Pages project at `functions/oip/inbox.js`. Its companion, `functions/_lib/oip_envelope.js`, is the shared envelope implementation — one file imported by every runtime (the Pages Functions handler and the standalone oip-peer Worker), using WebCrypto only and no external dependencies.

## The envelope

Every message on the wire is an `oip-message/1` envelope. The envelope is the unit of federation: it carries a protocol identifier, a unique message id, a conversation id, a kind, a sender (`from`), a recipient (`to`), timestamps, a body, a body hash, and a signature.

The protocol defines seven kinds, drawn from FIPA-ACL performatives and trimmed to what OIP needs: `query`, `propose`, `invoke`, `result`, `event`, `cancel`, and `error`. Each message declares what kind of speech act it is, so a receiver never has to guess intent from prose.

Two hard limits shape the wire: an envelope lives at most 15 minutes (900 seconds), and the inline payload ceiling is 65,536 bytes. Data larger than that travels by pointer rather than inline.

## Identity is not authority

The inbox verifies the envelope shape, freshness, body hash, and the sender's signature against the sender domain's `/.well-known/oip.json` document. This is identity verification — it proves which agent at which domain sent the bytes. It grants no authority by itself.

The law of the wire, stated in the envelope library, is that an envelope is data. Text inside the body is never an instruction. Only `kind:"invoke"` carrying a valid, audience-bound capability can make anything run, and the receiving server re-checks every gate itself. Signatures prove origin; they do not confer permission.

An unpublished sender — one whose well-known document cannot be resolved — may only send a query. Every other kind requires a resolvable signing key.

## The replay membrane

The inbox rejects any message id it has already seen. A message id is delivered at most once, ever. The seen-check runs first, but the id is only marked seen after the sender's signature verifies — so a forged, unverifiable envelope cannot poison a legitimate sender's future message id.

This is a replay membrane: a resent envelope never re-runs anything. The seen key is stored in KV with a 24-hour TTL.

## Query: data, not instructions

When the inbox receives a `query`, it echoes the body back as data. Nothing is executed. This is the explicit design point for prompt-injection resistance: a payload that says "ignore your rules and run X" is returned as data, not honored as an instruction.

The reply carries `retrieved_text_is_data: true` and a note: "A query grants and requires no authority. Message text is data, never an instruction — nothing was executed."

## Invoke: the only kind that runs

`invoke` is the only kind that can run an object. It requires a valid capability in `envelope.capability`. The inbox verifies the token's signature and expiry, looks up the capability record by nonce, and then runs a sequence of gates:

- **Audience binding**: the capability must be explicitly bound to a remote agent or domain. An ordinary unbound token cannot cross the federation boundary.
- **Audience match**: the capability's audience must match the verified sender. A capability handed to one agent cannot be used by another.
- **Scope**: the token must allow the requested key.
- **Chain**: no parent of the capability may be revoked or expired.
- **Contract and tenant**: the capability record's own gates and tenant isolation are re-checked.
- **Uses**: the capability must have remaining uses; an exhausted token is refused with 429.

Only after all gates pass does the inbox call `dispatch` to run the object. The result is wrapped with a proof, an invocation record, and an `on_behalf_of` chain that records the cryptographically verified sending agent as the immediate actor.

## Cross-ledger receipts

The reply to an invoke carries a `cross_ledger` block with three hashes: the request body hash (`request_body_sha256`), the input hash (`input_sha256`), and the output hash (`output_sha256`). The message id and invocation id are also present.

This is the join key: the two separately deployed ledgers — the sender's and the receiver's — can be joined by these hashes and the message id without either trusting the other. The sender can verify that the bytes it sent produced exactly the bytes the receiver claims to have produced.

## End-to-end encryption

If the inbound message was encrypted to the home node's key, the reply is sealed back to the sender's key, making the full round trip confidential over any transport. The encryption is ECDH-P256 with AES-GCM — deliberately simple ECIES. An ephemeral sender key means every message has a fresh shared secret. Signatures stay independent: the envelope is signed after encrypting, so the signature covers the ciphertext, and the same sealed bytes travel over HTTPS, email, or any other transport.

## Discovery

A federated agent is resolved through its domain's `/.well-known/oip.json` document. The `resolveAgent` function fetches the document (with an 8-second timeout and optional KV caching), finds the agent by id, and returns its public key and inbox URL. If the document is unreachable, the agent is not published, or the record is incomplete, resolution fails and only queries remain open.

## What the inbox does not do

The inbox does not trust transport alone. It does not treat message text as instructions. It does not let an unbound capability cross the federation boundary. It does not let a capability minted for one agent be used by another. It does not re-run a message id. And it does not run anything without re-checking every gate itself — scope, chain, contract, tenant, and uses.

The design is a single principle carried to its conclusion: identity is not authority, and data is not instruction. The inbox is the membrane where that principle is enforced.

## Sources

1. functions/oip/inbox.js — handler header — functions/oip/inbox.js
2. functions/oip/inbox.js — handler responsibilities 2 and 3 — functions/oip/inbox.js
3. functions/oip/inbox.js — handler responsibility 4 — functions/oip/inbox.js
4. functions/oip/inbox.js — handler responsibility 5 — functions/oip/inbox.js
5. functions/oip/inbox.js — replay membrane comment — functions/oip/inbox.js
6. functions/oip/inbox.js — query handling comment — functions/oip/inbox.js
7. functions/oip/inbox.js — invoke handling comment — functions/oip/inbox.js
8. functions/oip/inbox.js — federation check comment — functions/oip/inbox.js
9. functions/_lib/oip_envelope.js — law of the wire — functions/_lib/oip_envelope.js
10. functions/_lib/oip_envelope.js — message kinds — functions/_lib/oip_envelope.js
11. functions/_lib/oip_envelope.js — envelope limits — functions/_lib/oip_envelope.js
12. functions/_lib/oip_envelope.js — file header — functions/_lib/oip_envelope.js


---

# Cloudflare Access authenticates the edge, not your application

slug: cloudflare-os-access · https://miscsubjects.com/a/cloudflare-os-access · tags: cloudflare, architecture, security, cloudflare-os, access, zero-trust, authentication · updated 2026-07-26T06:20:14.182Z

Cloudflare Access sits between a request and an origin. For a person, it turns an application URL into an identity check: Cloudflare redirects the browser to an identity provider, applies an Access policy, and issues a signed session token. For a machine, there is no login page. It must send a service credential on the first request, and the Access policy must explicitly accept that credential.

The distinction that decides the design:

> Access proves that a request satisfied an edge policy. Your application still decides what that authenticated principal may do.

A service token can pass Access and still carry no human identity. A Bypass rule can make a path reachable while removing Access authentication and Access logging from that path. Deleting the application does not prove the service token was deleted, and deleting the token does not prove the application or policy disappeared. Those are separate objects with separate list and delete operations.

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## The request path, without product names hiding the mechanics

| Stage | Human request | Machine request |
| --- | --- | --- |
| 1. Request arrives | Browser requests the protected hostname and path | HTTP client requests the same URL |
| 2. Access checks credential | Looks for a valid `CF_Authorization` cookie | Looks for service-token headers or another non-human credential |
| 3. No valid credential | Redirects to the Access login flow | Usually a `302` the client cannot use, or `401`/`403` when Service Auth handling is configured |
| 4. Policy evaluation | Allow, Block, Bypass, or a more specific rule | Service Auth, mTLS, or Bypass |
| 5. Origin request | Cloudflare forwards `Cf-Access-Jwt-Assertion` | Cloudflare forwards an application JWT after service authentication |
| 6. Origin authorization | Verify signature, issuer and audience; map `email` or `sub` to an app role | Verify the same fields; map `common_name` to a synthetic machine principal |

Access is not an origin firewall. Unless the origin is connected only through Cloudflare Tunnel or otherwise restricted to Cloudflare, an attacker may try to reach it directly and avoid the Access layer. Even when every request must pass Cloudflare, the origin still verifies the JWT. Cloudflare's application-token reference is blunt: validation of the header alone is insufficient because an unverified header can be spoofed.

## Create one self-hosted application in the dashboard

Prerequisites: a Cloudflare account, a Zero Trust organization, a domain on Cloudflare, and an identity provider. The built-in one-time PIN flow is enough for a small first deployment; an organization using group rules should connect its existing SAML or OIDC provider and confirm the exact group claim before writing policy.

Current dashboard path:

1. Open **Zero Trust**.
2. Go to **Access controls** → **Applications**.
3. Select **Add an application**.
4. Choose **Self-hosted**.
5. Set **Application name**.
6. Under **Session Duration**, choose how long the application JWT remains valid.
7. Under **Add public hostname**, enter **Subdomain**, **Domain**, and optional **Path**. A path makes the Access application narrower than the hostname.
8. Under **Access policies**, create or attach a policy.
9. Choose the identity providers shown on the login page.
10. Save, then test one allowed identity and one denied identity before widening the selectors.

Access applications are deny-by-default. Creating the hostname without an Allow or Service Auth policy does not grant anyone access.

The four policy actions do different jobs:

| Action | What a match means | Correct use | Dangerous misunderstanding |
| --- | --- | --- | --- |
| **Allow** | The request may continue after identity authentication | People selected by email, IdP group, country, device posture, or another identity rule | “Not blocked” does not mean allowed; unmatched users remain denied |
| **Block** | The matching request is denied | Carve a narrow denial out of a broader Allow rule | A Block rule alone does not make everyone else allowed |
| **Bypass** | Access enforcement is disabled for the matching traffic | A deliberately public webhook or health path whose own exposure is accepted | No Access identity, controls, or Access logs remain on that path |
| **Service Auth** | A non-IdP credential may pass | Service tokens or mutual TLS for automation | A service token is not a human and may not have `sub` or `email` |

Policy order and selectors matter. Test with the policy tester, then make real HTTP requests. A green dashboard object is configuration evidence, not traffic evidence.

## The same application and policy through the REST API

Use a Cloudflare API token scoped to **Access: Apps and Policies Write**. Keep the account id and API token in environment variables; neither belongs in shell history, an article, or a CI log.

```sh
curl -sS -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/apps" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  -H "content-type: application/json" \
  --data '{
    "name": "admin surface",
    "type": "self_hosted",
    "domain": "admin.example.com",
    "session_duration": "8h",
    "app_launcher_visible": false,
    "service_auth_401_redirect": true
  }'
```

Capture `result.id` as `ACCESS_APP_ID`. Do not hand-type it.

```sh
curl -sS -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/apps/$ACCESS_APP_ID/policies" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  -H "content-type: application/json" \
  --data '{
    "name": "named administrators",
    "decision": "allow",
    "precedence": 1,
    "include": [
      {"email_domain": {"domain": "example.com"}}
    ]
  }'
```

The API response must say `success: true`. Follow it with a fresh GET of the exact application. A `201` proves creation, but the GET proves the stored hostname, policy and session settings are the ones you intended.

For infrastructure automation, create a service token separately:

```sh
curl -sS -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/service_tokens" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  -H "content-type: application/json" \
  --data '{"name":"deploy smoke","duration":"720h"}'
```

The client secret is returned once. Store it in the deployment secret store immediately. The token still does nothing until a policy accepts it:

```sh
curl -sS -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/apps/$ACCESS_APP_ID/policies" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  -H "content-type: application/json" \
  --data '{
    "name": "deployment machine",
    "decision": "non_identity",
    "precedence": 2,
    "include": [{"any_valid_service_token": {}}]
  }'
```

Cloudflare's API calls the `non_identity` decision **Service Auth** in the dashboard. That naming difference is worth writing in the runbook; otherwise an operator comparing JSON with the UI can think the wrong policy was created.

## A service token is two secret headers and one policy

The normal first request carries:

```sh
curl -sS https://admin.example.com/health \
  -H "CF-Access-Client-Id: $ACCESS_CLIENT_ID" \
  -H "CF-Access-Client-Secret: $ACCESS_CLIENT_SECRET"
```

Cloudflare checks the two values, evaluates a Service Auth policy, and forwards the request with `Cf-Access-Jwt-Assertion`. A successful request can also return a `CF_Authorization` cookie. If the application contains only Service Auth policies, Cloudflare requires the service token on subsequent requests too; the JWT cookie alone is not enough.

Access also supports a single custom header containing both values. That helps SaaS clients with one configurable authorization field. It does not help software with no custom-header extension point.

That limitation is common, not theoretical.

`kennypy` put Jellyfin behind Access. Google SSO worked in a browser, but the Findroid client could not add the two headers and became LAN-only. `hippiuS` hit the same shape with an MCP client calling ArgoCD: the request became a `302` to an SSO page a non-browser could not follow, or a `403`. The ArgoCD CLI needed a general `--header` flag before it could work with this class of proxy authentication.

The rule: **check the client's HTTP surface before choosing Access for the endpoint.** “It can call HTTPS” is insufficient. It must be able to set two headers, one configured compound header, mTLS credentials, or an Access-aware token.

## The service-token JWT has authority but may have no user

The recovered first-party probe created a temporary self-hosted application, added Service Auth, minted a temporary service token, and called a protected path. With valid headers, Access let the request reach the origin. The origin returned its own `404`, which is the useful proof: the credential cleared the edge policy.

The redacted application-token payload had this shape:

```json
{
  "type": "app",
  "iat": 1785041363,
  "exp": 1785043164,
  "iss": "https://<team-name>.cloudflareaccess.com",
  "sub": "",
  "aud": ["<application-audience>"],
  "common_name": "<service-token-client-id>"
}
```

There was no `email` claim. `sub` was the empty string.

`dataGriff` documented the consequence in a CI smoke test: once Access was enforced, the service-token caller had no user id to own a review and no human admin standing. The correct repair is not to invent an email inside every handler. Map the verified service principal once, at the authentication seam:

```js
function principalFromAccessClaims(claims) {
  if (claims.type === "app" && claims.common_name) {
    return {
      kind: "machine",
      id: `access-service:${claims.common_name}`,
      roles: ["deploy-smoke"],
    };
  }
  if (claims.email && claims.sub) {
    return {
      kind: "human",
      id: claims.sub,
      email: claims.email,
      roles: rolesForEmail(claims.email),
    };
  }
  throw new Error("Access token has no usable principal");
}
```

The application authorizes `deploy-smoke` to do only the smoke-test operations. It does not promote every service token to administrator. `common_name` is useful only after the JWT signature, issuer and audience have passed.

## Verify the JWT at the origin

Read `Cf-Access-Jwt-Assertion`. Cloudflare recommends that header because the cookie is not guaranteed to reach the origin. Then verify:

1. The signature against the team's JWKS.
2. `alg` is the expected RS256 algorithm.
3. `iss` equals the exact team-domain issuer.
4. `aud` contains the exact Access application audience tag.
5. `exp` and `nbf` permit the current time.
6. The resulting human or machine principal is authorized for this application action.

With `jose`:

```js
import { createRemoteJWKSet, jwtVerify } from "jose";

const TEAM_DOMAIN = process.env.ACCESS_TEAM_DOMAIN;
const ACCESS_AUD = process.env.ACCESS_AUD;
const issuer = `https://${TEAM_DOMAIN}`;
const jwks = createRemoteJWKSet(
  new URL(`${issuer}/cdn-cgi/access/certs`),
);

export async function requireAccess(request) {
  const token = request.headers.get("Cf-Access-Jwt-Assertion");
  if (!token) return { ok: false, status: 401, error: "missing Access JWT" };

  try {
    const { payload, protectedHeader } = await jwtVerify(token, jwks, {
      issuer,
      audience: ACCESS_AUD,
      algorithms: ["RS256"],
    });
    return {
      ok: true,
      claims: payload,
      algorithm: protectedHeader.alg,
      principal: principalFromAccessClaims(payload),
    };
  } catch {
    return { ok: false, status: 403, error: "invalid Access JWT" };
  }
}
```

Do not hard-code a PEM. The public endpoint carries the current signing key and the previous rotated key. The fresh read on this account returned two RSA signing keys, both RS256. A remote JWKS loader selects by `kid` and survives rotation.

## Bypass is a public route, not machine authentication

A Bypass policy removes Access from matching traffic. Cloudflare does not apply Access security controls to it, and the request is not present in Access logs. That can be correct for a public payment webhook whose provider cannot send Access credentials, provided the handler verifies the provider's own signature and rejects replay.

It is not a shortcut for a private API.

`lesbass` reported a split application where the unauthenticated health endpoint worked while every company-scoped API call failed with `RESPONSIBLE_USER_UNAVAILABLE`. The Access identity existed, but it did not map to a company member. Opening more paths would hide the identity defect by removing authentication from them.

Use the narrowest path possible. Put a separate handler-level signature on a bypassed webhook. Do not Bypass `/api/*` because one vendor callback needs to be public.

## Deleting one object proves nothing about the other two

An Access deployment usually creates at least three resources:

| Resource | What deleting it removes | What remains |
| --- | --- | --- |
| Access application | Hostname/path protection and attached application policies | Reusable policies and service tokens may remain |
| Application policy | One Allow, Block, Bypass or Service Auth decision | Application and credentials remain |
| Service token | That client id and secret | Application and Service Auth policy remain, ready to accept another valid token |

The proof sequence is explicit:

```sh
curl -sS -X DELETE \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/apps/$ACCESS_APP_ID" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"

curl -sS -X DELETE \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/service_tokens/$SERVICE_TOKEN_ID" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"

curl -sS \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/apps" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"

curl -sS \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/access/service_tokens" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"
```

Require HTTP success and Cloudflare `success: true` before examining either list. A `401`, `403`, or empty parse is not absence proof. The final lists must contain neither the exact id nor the exact temporary name.

The temporary measurement used for this page was rechecked at `2026-07-26T06:09:10Z`. Both authenticated lists succeeded. Neither the application id/name nor the service-token id/name was present. Zero probe resources remained.

## When a single bearer key is the better answer

This application's owner surface does not use Access. Its middleware accepts one owner key by request header or URL parameter, or a signed 60-day HttpOnly admin-session cookie minted after the key is entered once. An unauthenticated browser is redirected to the public login page. An unauthenticated machine request receives a bounded `401` JSON object.

That posture accepts a sharp trade: one strong secret has no person-level identity, no IdP offboarding and no device-posture check. In return, any HTTP client that can set one header can use it, the application controls the exact failure response, and machine calls do not depend on an SSO redirect.

For one owner and a closed automation surface, that can beat Access. For 20 administrators who need individual revocation and audit attribution, it does not.

| Option | Best fit | Identity | Machine-client requirement | Verdict |
| --- | --- | --- | --- | --- |
| Cloudflare Access | Several people, existing IdP, per-person revocation | Human email/groups; machine principal for service tokens | Custom headers, mTLS, or Access-aware client | Default for a shared admin UI |
| mTLS | Services or managed devices with certificate lifecycle | Certificate subject or mapped device | Client-certificate support | Strong machine auth; heavier issuance and rotation |
| One bearer key checked in the Worker | One owner, small fixed set of scripts | Shared principal only | One configurable header | Best simple answer when per-person identity adds no value |
| IP allow-list | Fixed corporate egress as one factor | Network location, not a person | Stable source IP | Use as a condition, not the only credential |
| Tunnel plus Access | Private origin that must not be directly reachable | Access identity plus private origin path | Browser login or service credential | Strongest Access topology for a self-hosted origin |
| Bypass plus handler signature | One third-party webhook | Provider key/signature | Provider-specific signed request | Correct for that path only |

## Seats and arithmetic

Cloudflare's current plan page says the Free plan is for teams under 50 users and costs $0. Pay-as-you-go is $7 per user per month. The page describes Remote Browser Isolation as an add-on but does not publish its current add-on price. A dated 2023 operator comparison recorded $10 per user per month; treat that as historical evidence, not today's quote.

| Administrators | Access Free | Pay-as-you-go at $7/seat/month | Shared bearer key |
| ---: | ---: | ---: | ---: |
| 1 | $0 | $7 | $0 product fee |
| 12 | $0 | $84 | $0 product fee |
| 49 | $0 | $343 | $0 product fee |
| 60 | Plan choice required; outside “under 50” positioning | $420 | $0 product fee, but 60 people sharing one key is indefensible |
| 250 | Not the free-plan fit | $1,750 | Wrong architecture |

The calculation is seats × $7. It excludes support, identity-provider cost, implementation time, and any separately quoted Remote Browser Isolation add-on. A bearer key has no Cloudflare seat line item, but secret rotation and the absence of individual attribution are costs; they are just paid in operator time and incident risk.

## What administering Access feels like

The policy surface is capable. The console has drawn specific criticism. `systemvoltage`, otherwise positive about Cloudflare's main dashboard, described the Access/Zero Trust area as a separate application that took ten seconds and redirected repeatedly, with worse UI and thin documentation.

That report is dated 2022. Do not turn it into a claim about today's page speed. Keep the durable operational lesson: the person on call needs the API paths and curl proofs in the runbook, because a graphical console can be slow, moved, or unavailable.

The positive operator case is equally concrete. `tbhb` uses Tunnel plus Access to expose only the local-development endpoints that must be public, such as webhooks, while keeping the rest of the site behind Access. That is the product boundary working: narrow public ingress, authenticated private remainder, and no directly published origin.

## Error, cause, repair

| Symptom | Cause | Repair |
| --- | --- | --- |
| `302` to `*.cloudflareaccess.com/cdn-cgi/access/login/...` | No accepted credential and the application is using interactive login behavior | Browser: complete the IdP flow. Machine: send a service token and add Service Auth, or enable the documented 401 response for Service Auth |
| `403` before the origin | Invalid service headers, no matching policy, wrong application path, or denied selector | Confirm both header names, list the application and policies, then test the exact hostname/path |
| Valid service token reaches origin but `sub` is empty | Service-token application JWT is non-human | Map verified `common_name` to a least-privilege synthetic machine identity |
| JWT signature verification fails | Wrong issuer, wrong audience, stale hard-coded key, altered token, or wrong algorithm | Fetch the team JWKS, select by `kid`, require RS256, exact issuer and exact application audience |
| Browser works; native client gets 302/403 | Client cannot add Access service-token headers | Add a general custom-header option, use the single-header mode, mTLS, or do not put that endpoint behind Access |
| Health works; every scoped API call fails | Health is public/Bypass while authenticated principal is not mapped into app membership | Fix identity mapping at the auth seam; do not widen Bypass |
| Origin accepts a claimed Access header without cryptographic verification | Application trusts attacker-supplied text | Verify JWT signature, issuer, audience and time before reading identity |
| Temporary app appears deleted but token remains | Only the Access application was deleted | Delete the service token separately; require fresh successful lists for both collections |

## Three live receipts, with bounded claims

**Access path.** A temporary application protected a unique path. Plain HTTP returned `302` before Service Auth and `403` after the Service Auth policy existed. Correct service-token headers passed Access and reached the origin, which returned its own `404`. The redacted JWT used RS256, had `type: "app"`, an audience and `common_name`, an empty `sub`, and no `email`.

**Signing keys.** A fresh unauthenticated GET of the account's team JWKS returned HTTP `200`, 4,914 JSON bytes and two RSA/RS256 signing keys. The published command uses a placeholder, not the real team name:

```sh
curl -sS "https://<team-name>.cloudflareaccess.com/cdn-cgi/access/certs" \
  | jq '{keys: [.keys[] | {kid, alg, kty, use}]}'
```

**This application's key-only admin gate.** A fresh machine request with no credential:

```sh
curl -sS -D - https://miscsubjects.com/admin \
  -H 'accept: application/json'
```

returned HTTP `401`, `application/json`, and a 47-byte body with only `error` and `login`. A scan found no stack, trace, binding, database, exception or key marker. This proves the unauthenticated failure is bounded; it does not prove the shared-key posture has person-level identity.

**Cleanup.** Successful authenticated Access application and service-token lists proved the temporary ids and names absent. No `401`, `403`, or failed parse was treated as an empty list.

Access earns its complexity when identity changes the authorization decision. If every accepted caller is the same owner and every client already holds the same operational secret, one checked key is smaller and often more reliable. Once individual revocation, IdP groups or device posture matter, use Access, verify the JWT at the origin, and give machines a principal of their own.

This chapter is part of [the Cloudflare account inventory](/a/cloudflare-os). For the private-origin and Durable Object boundary, see [Workers and Durable Objects](/a/cloudflare-os-workers).


## Sources

1. Authorization cookie — https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/
2. Application token — https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/application-token/
3. Validate JWTs — https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/
4. Access policies — https://developers.cloudflare.com/cloudflare-one/access-controls/policies/
5. Service tokens — https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/
6. Add a self-hosted application — https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app/
7. Create an Access application — https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/applications/methods/create/
8. Create a service token — https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/service_tokens/methods/create/
9. Zero Trust services plans — https://www.cloudflare.com/plans/zero-trust-services/
10. Remote Browser Isolation — https://developers.cloudflare.com/cloudflare-one/remote-browser-isolation/
11. Cloudflare Tunnel — https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/
12. panva/jose — https://github.com/panva/jose
13. Add support for Cloudflare Access Service Tokens (Custom Headers) — https://github.com/jarnedemeulemeester/findroid/issues/1016
14. Support custom HTTP headers on outbound ArgoCD API requests — https://github.com/argoproj-labs/mcp-for-argocd/issues/115
15. Give Access service-token callers a usable identity — https://github.com/dataGriff/outcome-app-pattern-whiskey/issues/4
16. Cloudflare Access blocker prevents Paperclip API operations — https://github.com/lesbass/ai-newsroom/issues/4
17. Comment on Cloudflare's Access console — https://news.ycombinator.com/item?id=31332325
18. Comment on Browser Isolation pricing — https://news.ycombinator.com/item?id=35494875
19. Comment on Tunnel plus Access — https://news.ycombinator.com/item?id=41915668
20. Temporary Access request-path receipt — https://miscsubjects.com/api/articles/cloudflare-os-access
21. Service application-token receipt — https://miscsubjects.com/api/articles/cloudflare-os-access
22. Fresh JWKS and owner-gate receipt — https://miscsubjects.com/api/articles/cloudflare-os-access
23. Temporary resource deletion proof — https://miscsubjects.com/api/articles/cloudflare-os-access


---

# Browser Rendering is an evidence adapter, not a better fetch()

slug: cloudflare-os-browser · https://miscsubjects.com/a/cloudflare-os-browser · tags: cloudflare, cloudflare-os, browser-run, browser-rendering, puppeteer, web-scraping, evidence, receipts, security · updated 2026-07-26T03:59:42.985Z

# Browser Rendering is an evidence adapter, not a better `fetch()`

A plain HTTP client retrieves bytes. Cloudflare Browser Run can execute the page, wait for its state to settle, and return a representation chosen for the next operation: rendered HTML, Markdown, selected elements, links, a screenshot, a PDF, an accessibility tree, structured JSON, or an asynchronous crawl.

That distinction is the whole chapter. A browser belongs in this system only where the evidence depends on browser execution or a browser-specific representation. It should not become the default transport. Making it the default spends more time and money, enlarges the security boundary, and can still return a convincing but incomplete page.

The capability catalogue therefore does not contain one vague `BROWSER` tool. It contains explicit contracts: what representation is requested, what completion condition is required, what authority may leave the system, and what receipt must come back. The browser is the eyes. The canonical catalogue decides when those eyes may open and what counts as seeing.

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## The endpoint is a choice about evidence

Cloudflare exposes ten Quick Actions in the current documentation, including the beta crawl action. They overlap at the input—usually a URL—but not at the output. Choosing by convenience rather than by evidence type is how a screenshot gets mistaken for data, a Markdown conversion gets mistaken for the DOM, or a link inventory gets reconstructed expensively from a general browser session.

| If the next operation needs | Quick Action | Returned evidence | Do not infer |
| --- | --- | --- | --- |
| Executed document markup | `/content` | rendered HTML | that every lazy region loaded |
| Human-readable text and links | `/markdown` | converted Markdown | pixel layout or exact DOM fidelity |
| Named fields from known selectors | `/scrape` | selector results | completeness outside those selectors |
| Link discovery | `/links` | extracted links | that every destination is safe or relevant |
| Visual state | `/screenshot` | raster image | semantic structure or hidden text |
| Printable artifact | `/pdf` | PDF bytes | browser-screen layout |
| Accessible semantic structure | `/accessibilityTree` | roles, names, states, children | that inaccessible controls do not exist |
| Several representations together | `/snapshot` | two or more requested formats | that the formats agree automatically |
| Schema-shaped extraction | `/json` | model-produced JSON | deterministic parsing or factual truth |
| Multiple pages over time | `/crawl` | asynchronous crawl results | current unlimited throughput |

`/snapshot` is especially useful for evidence work because one browser state can yield a visual surface and structural surfaces together. Cloudflare says the action defaults to HTML plus screenshot and can add Markdown and the accessibility tree. That is not just fewer requests. It reduces the chance that two captures were made from different page states. The receipt should still record each format separately and hash the bytes separately, because a screenshot and HTML prove different things.

The inverse rule matters too. If a stable endpoint already returns JSON, call it with ordinary HTTP. If static HTML contains the needed text, use ordinary HTTP. If all that is required is a status code or header, a browser weakens the measurement by adding navigation, rendering and conversion work that the question never asked for.

## A browser can execute a page without proving the page is complete

JavaScript execution is necessary for many modern pages, but it is not a completion oracle. Single-page applications often paint an initial shell, issue more requests, then reveal content after a selector appears. Cloudflare's Quick Action documentation repeatedly warns that the default result may be incomplete for SPAs and points to `waitForSelector` or navigation wait options.

That means every browser capability needs an explicit completion contract. “Open this URL” is not one.

| Completion contract | Good for | Failure it prevents |
| --- | --- | --- |
| `waitUntil: "domcontentloaded"` | server-rendered page with small client enhancement | waiting for irrelevant long-lived connections |
| `waitUntil: "networkidle0"` | bounded application that becomes quiet | capturing before dependent requests finish |
| `waitForSelector: "#results"` | a known state transition | treating the application shell as the result |
| fixed delay | almost nothing by itself | none; it only moves the race |
| application assertion | login, checkout, dashboard state | proving the wrong authenticated or error state |

A useful row therefore separates navigation from success:

```json
{
  "key": "BROWSER_MARKDOWN",
  "what": "Return Markdown after the named page state exists.",
  "args": {
    "url": "https URL",
    "wait_for_selector": "optional CSS selector",
    "timeout_ms": "bounded integer"
  },
  "authority": {
    "hosts": ["developers.cloudflare.com"],
    "cookies": false,
    "custom_headers": []
  },
  "receipt": {
    "final_url": true,
    "status": true,
    "browser_ms": true,
    "body_sha256": true,
    "selector_observed": true
  }
}
```

The row is discoverable because its `what` names Markdown and page state. It is invokable because the arguments are concrete. It is auditable because the allowed hosts and credential channels are visible. It is replayable because the receipt records the final URL, completion observation and content hash. The same row can project to REST documentation, a model tool schema, a CLI command and an admin form without inventing four contracts.

## The first-party receipt: 208 browser milliseconds, not a speed claim

On 26 July 2026 this build called the production `/browser-rendering/markdown` REST action against `https://example.com`. The token was read from the local credential store and was never copied into the artifact. The response was successful and contained the expected “Example Domain” heading.

| Fresh measurement | Result |
| --- | ---: |
| Cloudflare response status | 200 |
| Client-observed elapsed time | 1,418 ms |
| `X-Browser-Ms-Used` | 207.706 ms |
| API response bytes | 199 |
| Returned Markdown characters | 167 |
| Expected heading present | yes |

This is a receipt for one request from one client to one stable target. It is not a latency benchmark, an availability claim, or evidence that arbitrary protected sites will render. Client elapsed time includes network and API overhead. The browser-time header measures billable browser work for that Quick Action, not total wall time.

The reproduction is deliberately small:

```bash
curl -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/browser-rendering/markdown" \
  -H "Authorization: Bearer $BROWSER_RENDERING_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"url":"https://example.com"}'
```

The portable version should read the account identifier and token from environment or a secret store, never from a catalogue row, prompt, receipt or shell history. Record the response status, final representation hash and `X-Browser-Ms-Used`; discard the bearer token before ledgering.

## The bill is browser time, and sessions add a second meter

Cloudflare distinguishes Quick Actions from Browser Sessions. Quick Actions are charged for browser hours. Direct sessions through Puppeteer, Playwright or CDP are charged for browser hours and, on paid plans above the included allowance, concurrent browsers.

The current published table gives Workers Free ten browser minutes per day. Workers Paid includes ten browser hours per month and then charges $0.09 for each additional browser hour. Browser Sessions include three concurrent browsers on Free; Paid includes ten averaged monthly and then lists $2 for each additional concurrent browser. The Quick Action response header reports browser milliseconds used, which is the useful per-invocation receipt field.

| Cost or limit surface | Workers Free | Workers Paid default |
| --- | ---: | ---: |
| Browser time | 10 minutes/day | 10 hours/month, then $0.09/hour |
| Quick Action rate | 1 request/10 seconds | 10 requests/second |
| Session browsers | 3 concurrent | 120 concurrent limit |
| Included session concurrency for pricing | 3 | 10 monthly-average daily peak |
| New session instances | 1 every 20 seconds | 1/second |
| Inactivity timeout | 60 seconds | 60 seconds |
| Configurable inactivity timeout | up to 10 minutes | up to 10 minutes |

The 120-browser paid limit and the ten-browser paid price inclusion answer different questions. Conflating them makes a cost table wrong. So does multiplying the 208 ms receipt by the $0.09 rate and presenting the fraction of a cent as an invoice: Cloudflare aggregates daily seconds and rounds the monthly browser-hour total. The individual header supports attribution and anomaly detection; billing still follows the aggregate rules.

Direct sessions need stricter lifecycle code:

```js
let browser;
try {
  browser = await puppeteer.launch(env.BROWSER);
  const page = await browser.newPage();
  await page.goto(target, { waitUntil: "networkidle0" });
  return await page.content();
} finally {
  if (browser) await browser.close();
}
```

Cloudflare warns that a session left open continues consuming browser time until the inactivity timeout. An issue in `cloudflare/workers-sdk` also reported `browser.close()` hanging under local Vite and Wrangler development while production worked. That report is one historical local-development reproduction, not evidence that current production close calls hang. It is enough to justify a bounded close operation, a recorded close reason and a test of local and deployed paths separately.

## “Cloud browser” does not mean “bypass”

Changing the User-Agent does not turn Browser Run into an unidentifiable residential client. Cloudflare states that Browser Run requests are always identified as bot traffic and that a custom User-Agent does not bypass bot protection. A remote Chrome may execute client JavaScript that plain fetch cannot, yet the destination can still challenge or refuse it.

This has two consequences.

First, the browser capability must report refusal as refusal. A rendered challenge page with status 200 is not the requested article. Success needs a content assertion: selector observed, expected heading present, schema satisfied, or another target-specific check.

Second, the system must not market Browser Run as a way around a publisher's controls. Robots rules, authorization, terms, rate limits and data handling remain part of the invocation policy. Browser execution changes the client. It does not confer permission.

One Hacker News commenter said they moved to remote browser rendering because bot protection made direct fetching unworkable. Another asserted that Perplexity was using Cloudflare Browser Rendering for scraping. Those are observations from named operators, not universal proof of bypass, permission, scale, reliability or present product behavior. They establish that practitioners reach for this category of tool in the exact gap between plain HTTP and executed pages. They do not settle whether any particular target should be fetched.

## The output can be wrong even when the browser worked

Transport success and representation correctness are separate gates. A GitHub report against `/crawl` showed root-relative image paths being resolved as page-relative paths in converted Markdown, producing broken image URLs while the HTML output remained correct. That is an externally reported converter defect on particular pages, not proof that all current Markdown is broken. It demonstrates why the receipt should retain the source URL, format, converter version when available, and a second representation for material captures.

For critical evidence:

1. capture rendered HTML plus the representation used downstream;
2. retain the final URL after redirects;
3. hash both outputs;
4. validate required links or fields against the HTML;
5. label model-extracted JSON as derived;
6. store a screenshot when the claim is visual;
7. fail closed when a required selector or assertion is absent.

The `/json` action deserves an extra warning. Cloudflare documents it as AI-assisted extraction and says the default model is Workers AI's Llama 3.3 70B FP8 Fast unless another provider is supplied. A schema can constrain shape. It cannot make the content deterministic or true. JSON produced by a model is derived evidence and should preserve the prompt, schema, model identity, input hash and validation result. It should never overwrite the rendered source.

| Evidence status | Browser example | What can be claimed |
| --- | --- | --- |
| observed | screenshot visibly contains an error banner | the banner was visible in that capture |
| derived | model maps rendered page into a product schema | the model produced fields from that input |
| specified | Cloudflare documents a request limit | the published contract states the limit |
| implemented | catalogue row and adapter exist in code | this version contains the path |
| deployed | production endpoint accepts the row | the deployed version exposes it |
| reproduced | controlled call returns the expected representation | the tested input worked at that time |
| externally attested | named operator reports a failure or use | that operator reported that experience |

## Crawl is a queue, not a big page request

The beta `/crawl` action is asynchronous. A POST creates a job; subsequent reads retrieve status and results. Cloudflare says jobs may run for up to seven days and results remain available for fourteen days. That temporal shape belongs in the catalogue contract. A row that blocks a model turn until an entire crawl completes is the wrong projection.

Use three capabilities instead:

```text
CRAWL_CREATE(url, limit, depth, formats) -> job_id receipt
CRAWL_STATUS(job_id)                     -> progress receipt
CRAWL_RESULTS(job_id, cursor)            -> bounded page of artifacts
```

The catalogue can project those rows into an asynchronous REST API, terminal commands and model tools while retaining one authority policy and one lineage chain. Each result page should point back to the create receipt and catalogue snapshot.

Cloudflare's current Free limits specify five crawl jobs per day and one hundred pages per crawl. A March 2026 Hacker News comment multiplied those two numbers and questioned a 500-page daily ceiling. That is a reasonable reading of the Free limits now published, but the commenter described the documentation they saw and framed the concern more broadly. It is not independent evidence of a paid-plan cap. The current limits page says paid defaults can be increased and does not list the same crawl-specific table under Paid. The article therefore narrows the anecdote instead of repeating it as a current universal limit.

A second operator built a two-script, zero-dependency CLI covering all nine REST endpoints then documented, including `/crawl`. That externally attests that the REST surface was usable as a coherent toolset for one builder. It does not prove our adapter, our credentials or today's endpoint. Our own proof remains the measured `/markdown` receipt above.

## The authority boundary is larger than the URL

A browser can send cookies, custom headers, HTTP credentials and injected scripts. It can follow redirects to a different host, load subresources from many hosts, download data, and execute code supplied by the destination. Treating authority as an allowlist on the initial URL is inadequate.

The minimum policy envelope includes:

| Boundary | Required control |
| --- | --- |
| scheme | allow `https:`; reject `file:`, `data:`, local protocols |
| destination | resolve DNS and reject private, loopback, link-local and metadata addresses |
| redirects | revalidate every redirect target |
| subresources | block or constrain hosts when the task permits |
| credentials | declare exactly which cookies, headers or HTTP auth may leave |
| scripts | prohibit untrusted catalogue rows from injecting code |
| downloads | disable or quarantine with size and type limits |
| duration | bounded navigation, selector and overall operation timeouts |
| wallet | per-invocation browser-ms budget and caller quota |
| output | byte limit, format validation, hashing and secret scan |

This is SSRF defense and denial-of-wallet defense in one place. The model should never receive a raw “browse any URL with these headers” primitive when a narrower row can express the job. A malicious directory row must not be able to expand its own host authority, supply a metadata address, or ask the adapter to return cookies in the receipt.

The receipt should be useful without becoming a credential leak:

```json
{
  "capability_key": "BROWSER_MARKDOWN",
  "catalogue_version": "sha256:…",
  "requested_url": "https://example.com",
  "final_url": "https://example.com/",
  "authority_policy": "public-docs-v3",
  "completion": {"kind": "heading", "observed": true},
  "format": "markdown",
  "http_status": 200,
  "browser_ms_used": 207.706,
  "elapsed_ms": 1418,
  "body_sha256": "sha256:…",
  "credentials": {"token": "redacted", "cookies_sent": false}
}
```

Replay means invoke the same capability version with the same public inputs and policy, then compare receipts. It does not mean persist and resend an expired token. Repair means change the row or adapter under review—perhaps the selector, format, timeout or allowed host—then issue a new catalogue version and preserve the failed receipt. The ledger makes failure part of lineage instead of rewriting history.

## REST for bounded transforms; Puppeteer for interaction

Quick Actions cover common one-shot representations with smaller contracts. Puppeteer or Playwright is appropriate when the task genuinely requires interaction across states: click, type, authenticate, paginate, reuse a session, inspect requests, or coordinate multiple pages.

| Requirement | Prefer | Reason |
| --- | --- | --- |
| one URL to Markdown | Quick Action | bounded request and direct browser-time receipt |
| screenshot plus HTML | `/snapshot` | representations share one capture |
| named CSS fields | `/scrape` | selector contract is explicit |
| multi-page site collection | `/crawl` | asynchronous job semantics |
| click through a flow | Puppeteer/Playwright | stateful interaction |
| persistent authenticated workspace | reusable session | cookies and state are intentional |
| stable public JSON endpoint | ordinary `fetch()` | no browser evidence is needed |

The decision can be mechanized in the canonical catalogue. Discovery exposes the specific transform first. Authority hides session tools from callers that do not need credentials or interaction. Invocation validates URL and completion conditions. Receipts normalize REST and session results into the same lineage fields. Repair can replace an implementation without changing the capability's public meaning.

This is where the system claim becomes concrete. One catalogue row drives discovery text, input schema, authority, adapter selection, receipt shape, replay, repair documentation, model-tool projection, CLI help and admin controls. The browser is not a second architecture. It is one implementation family behind the catalogue.

## What the operator reports change—and what they do not

The people-source set is deliberately mixed.

- A Cloudflare engineer reported `browser.close()` hanging in local Vite and Wrangler development while production succeeded. This supports testing local and deployed lifecycle separately.
- A user reported REST error codes 7003 and 7000 despite a token and account identifier they had verified. This supports returning Cloudflare's structured error body and the chosen endpoint in the receipt; it does not prove the present API is generally misconfigured.
- A crawl user reported malformed root-relative image URLs in Markdown while HTML stayed correct. This supports cross-format validation.
- A commenter questioned crawl throughput based on the published limit arithmetic. This supports showing the limit calculation and reading the current plan table, not a universal paid-plan conclusion.
- A CLI author reported exercising the full REST family. This supports the coherence of Quick Actions as a practical interface for that author.
- Two other commenters described using or observing Browser Rendering for scraping and Markdown distillation. These support the use case, not permission, bypass success, commercial scale or adoption.

Operator evidence is valuable here because it reveals failure modes absent from a happy-path reference: lifecycle hangs, auth-shaped errors, converter defects and throughput surprises. It remains externally attested evidence. The specification defines the contract; the fresh receipt establishes what this build reproduced; operator reports tell us which edges deserve tests.

## The operating rule

Use Browser Run when the thing you need does not exist until a browser executes the page, or when the required artifact is browser-specific. Name the representation. Name the completion condition. Constrain the authority. Meter browser time. Preserve the source alongside every derived form.

Do not call it a bypass. Do not call model-shaped JSON fact. Do not call one successful render availability. Do not call a historical issue a current universal defect.

When those boundaries are encoded once in the capability catalogue, the same browser operation can be discovered by a model, invoked from a terminal, projected as an API, receipted in the ledger, replayed after a change and repaired without losing its history. That—not remote Chrome by itself—is what makes Browser Rendering part of an operating system.

## Sources

1. Browser Run Quick Actions overview — https://developers.cloudflare.com/browser-run/quick-actions/
2. /markdown — Extract Markdown from a webpage — https://developers.cloudflare.com/browser-run/quick-actions/markdown-endpoint/
3. /snapshot — Capture multiple page formats — https://developers.cloudflare.com/browser-run/quick-actions/snapshot/
4. /content — Fetch rendered HTML — https://developers.cloudflare.com/browser-run/quick-actions/content-endpoint/
5. /accessibilityTree — Capture the accessibility tree — https://developers.cloudflare.com/browser-run/quick-actions/accessibility-tree-endpoint/
6. /scrape — Scrape HTML elements — https://developers.cloudflare.com/browser-run/quick-actions/scrape-endpoint/
7. /json — Capture structured data using AI — https://developers.cloudflare.com/browser-run/quick-actions/json-endpoint/
8. /crawl — Crawl web content — https://developers.cloudflare.com/browser-run/quick-actions/crawl-endpoint/
9. Browser Run pricing — https://developers.cloudflare.com/browser-run/pricing/
10. Browser Run limits — https://developers.cloudflare.com/browser-run/limits/
11. Puppeteer on Browser Run — https://developers.cloudflare.com/browser-run/puppeteer/
12. /screenshot — Capture a screenshot — https://developers.cloudflare.com/browser-run/quick-actions/screenshot-endpoint/
13. miscsubjects architecture — https://github.com/redacted/miscsubjects-architecture
14. Cloudflare crawl endpoint — https://hn.algolia.com/api/v1/items/47332926
15. BUG: browser rendering browser.close() hangs — https://github.com/cloudflare/workers-sdk/issues/9945
16. Cloudflare Browser Rendering API (Code 7003/7000) Failure in Worker — https://github.com/cloudflare/workers-sdk/issues/10864
17. Browser Rendering /crawl API: Markdown converter incorrectly resolves root-relative image URLs — https://github.com/cloudflare/workers-sdk/issues/13406
18. Perplexity is using stealth, undeclared crawlers to evade no-crawl directives — https://hn.algolia.com/api/v1/items/44788890
19. Cloudflare crawl endpoint — https://hn.algolia.com/api/v1/items/47348398
20. ChatGPT won't let you type until Cloudflare reads your React state — https://hn.algolia.com/api/v1/items/47572417
21. Fresh first-party Browser Run /markdown receipt — https://miscsubjects.com/api/articles/cloudflare-os-browser


---

# Fable finding #47 — BLOOIO risk class fixed

slug: oip-fable-finding-47-fix · https://miscsubjects.com/a/oip-fable-finding-47-fix · tags: oip, security, fable, risk-ceiling, self-explaining · updated 2026-07-17T02:36:12.024Z

# Fable finding #47 — shipped

## §SELF — oip-fable-finding-47-fix

**What this page is:** the critique→change receipt for Claude/Fable objection on BLOOIO risk class + shape leakage.
**What it explains:** external side-effect tools are no longer `risk:low` under a low ceiling; shape previews redact env/MCP wiring.
**Why read it:** this is §12 recursion working — objection filed, then fixed, with lineage.

### Finding
- `BLOOIO_SEND_MESSAGE` (and peers) were `sensitive=0` / risk low → low-ceiling act tokens could send real messages.
- `shape:true` preview leaked `BLOOIO_API_KEY_PEPPERUP` and MCP URL wiring.

### Fix (2026-07-15)
1. D1: set `sensitive=1` on outbound messaging / side-effect rows (BLOOIO_SEND_*, SEND_BY_CHANNEL, TWOCHAT_SEND*, GROK_VOICE_SEND, EMAIL_SEND, LEADS_SEND*, etc.).
2. dispatch.js: deeper shape/request redaction — env-like names, `$ENV` refs, MCP URLs.
3. INVALIDATE_DIR_SNAPSHOT so contracts flip live.

### Proof
Low act token: shape or invoke BLOOIO_SEND_MESSAGE → `risk_ceiling:low<row:high`. NOW still runs.

### Links
- Objection surface: file via OBJECTION_LOG on oip-spec
- Protocol drop §1 worst-case is honest again for low-ceiling keys


