Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
Guides

Hosting

Protect private docs with Cloudflare Access

A private docs site on Cloudflare Workers that only your company can open, with every page, Markdown copy, search index, and agent endpoint checked behind Access.

By 10 min read

Blume has no sign-in of its own, so a private documentation website relies on the access control in front of it. On Cloudflare, that means deploying the Blume site to a Worker and turning on Cloudflare Access for the whole Worker. Access checks every request at Cloudflare's edge before the Worker or its static files answer, and sends anyone without a session to your company's sign-in. You write no login code, and Blume needs no changes.

By the end you have a test handbook at handbook.acme.example, a policy that admits only your company's accounts, a service token for scripts and MCP clients, and a script that requests every URL the site serves on every hostname the Worker answers on. Replace acme.example with a domain on your Cloudflare account.

If your docs already deploy to Vercel or Netlify, their built-in protection is less work: see Private docs. Either way, protection covers the whole site. Blume has no per-page permissions, so pages that must stay public belong in a separate site.

Build a test handbook

Start with a test handbook that holds nothing sensitive, since it's public until you turn on Access. One page carries a canary, a made-up string: if it ever shows up in a response you got without signing in, something isn't covered.

Create a folder with this package.json:

{
  "name": "acme-handbook",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "blume dev",
    "build": "blume build",
    "deploy": "wrangler deploy"
  }
}

Install Blume, then the Cloudflare adapter and Wrangler, which a Cloudflare build needs in the project:

npm install blume
npm install --save-dev @astrojs/cloudflare@^14.3.3 wrangler@^4.142.0

Add two pages:

---
title: Acme Handbook
description: How we work at Acme.
---

This is a test handbook. Nothing here is confidential.

- [Onboarding](./onboarding.md)
---
title: Onboarding
description: Your first week at Acme. HANDBOOK-CANARY-4417
---

HANDBOOK-CANARY-4417. If you can read this without signing in, the
handbook isn't private.

## Your first day

Pick up your laptop, then find your onboarding buddy.

Then the config. Naming the cloudflare() adapter switches the build to server output, which the MCP server and the search endpoint need. The pages are still prerendered files.

import { defineConfig } from "blume";
import { cloudflare } from "blume/deploy";

export default defineConfig({
  title: "Acme Handbook",
  deployment: cloudflare({ site: "https://handbook.acme.example" }),
  agents: {
    mcp: { enabled: true },
  },
  ai: {
    // Chat apps can't open a page behind Access.
    openInChat: false,
  },
});

site is the one hostname people will use, which a Worker build doesn't learn from the platform. openInChat: false hides the page actions that hand a page's URL to ChatGPT or Claude, which can't get past the sign-in.

Last, the Wrangler config. Blume reads a wrangler.jsonc at the project root and carries it into the Worker it builds:

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "acme-handbook",
  "compatibility_date": "2026-09-01",
  "compatibility_flags": ["nodejs_compat"],
  // The one hostname the handbook answers on.
  "routes": [{ "pattern": "handbook.acme.example", "custom_domain": true }],
  // No workers.dev hostname and no preview URLs.
  "workers_dev": false,
  "preview_urls": false,
  "assets": {
    "binding": "ASSETS",
    "html_handling": "drop-trailing-slash",
    "not_found_handling": "404-page",
    // Blume adds its own rules to this list at build time.
    "run_worker_first": ["/mcp"]
  }
}

The routes entry attaches the Worker to handbook.acme.example as a custom domain, with its DNS record and certificate. workers_dev and preview_urls switch off the Worker's other addresses: its workers.dev hostname and per-version preview URLs. The assets block is the one useblume.dev runs on; Deploy documentation and an MCP endpoint to Cloudflare Workers explains each line, and covers the build and Wrangler in more depth.

Deploy the Worker

Build, sign Wrangler in to your Cloudflare account, and deploy:

npm run build
npx wrangler login
npm run deploy

The build writes the Worker to dist/server and leaves Wrangler a pointer to it in .wrangler/deploy/, so wrangler deploy from the project root ships that build. Open https://handbook.acme.example/onboarding: anyone can read it for now.

Put the whole Worker behind Access

Access can protect a hostname or a whole Worker. Protect the Worker: Cloudflare then applies Access to every domain associated with it, including its routes, custom domains, workers.dev hostname, and previews. That covers the origin as well as the public hostname, including any address someone switches on later.

Access needs Zero Trust set up on your account. Then:

  1. In the Cloudflare dashboard, go to Workers & Pages and select acme-handbook.
  2. Open the Access tab and select Protect this Worker behind Access.
  3. Choose All traffic. Previews only leaves the custom domain public.
  4. Under Authentication policy, choose Email domain and enter acme.example.
  5. Select Apply Access.

Reload the onboarding page, and you land on a Cloudflare sign-in page. If every Worker in the account is internal, the Protect all Workers card on the Workers & Pages overview protects all of them, including future ones, so a new site is never public before this step.

Choose who can sign in

The Email domain option admits anyone with a verified address at your domain. Refine it in Zero Trust under Access controls > Applications, where the Worker's application now lives.

Sign-in methods

People sign in with the methods under Zero Trust > Integrations > Identity providers. Add your company's identity provider there, such as Google Workspace, Okta, or Microsoft Entra ID. To test without one, add One-time PIN, which emails a sign-in code instead. Then edit the application, select the methods it offers, and turn on Apply instant authentication if there's only one, so people go straight to your company's sign-in.

The policy

Access denies by default: only people who match an Allow policy get in. The Email domain option created an Emails ending in rule for @acme.example. With Okta, Microsoft Entra ID, Google, or GitHub connected, an Identity provider group rule narrows it to one team, and an Exclude rule shuts out named accounts. Identity rules are checked at sign-in, so a change reaches signed-in people when their session expires, as set by the application's Session Duration.

A service token for scripts and agents

Scripts and MCP clients can't complete a browser sign-in, so give them a service token:

  1. Go to Zero Trust > Access controls > Service credentials > Service Tokens and select Create Service Token. Name it after who or what uses it, choose a duration, and select Generate token.
  2. Copy the Client ID and Client Secret. Cloudflare shows the secret only once.
  3. Under Access controls > Policies, select Add a policy. Set the action to Service Auth and add an Include rule with the Service Token selector and your token.
  4. Add the policy to the handbook application.

Keep both values in your shell for the checks below:

export CF_ACCESS_CLIENT_ID="your-client-id.access"
export CF_ACCESS_CLIENT_SECRET="your-client-secret"

Test allowed and denied people

Use a new private browser window for each check, so no earlier session carries over.

  • Allowed. Open https://handbook.acme.example/onboarding and sign in with an account the policy allows. The page loads. Search for "first day" from the header: results come from the site's search index, fetched with your session. Then open /onboarding.md, and the raw Markdown loads too.
  • Denied. Sign in with an account outside the policy, like a personal address. Access shows "That account does not have access." With One-time PIN, a blocked address gets no email at all, although the page still says a code was sent.

Test every representation and hostname

A Blume site serves each page in several forms, and some files hold text from every page. The only way to know Access covers them all is to request each one. This is the access matrix for the test handbook:

SurfaceWhat to request
Pages/, /onboarding, and the build's files under /_astro/
Markdown copies/onboarding.md, /onboarding.mdx, and /onboarding with Accept: text/markdown
llms.txt/llms.txt, /llms-full.txt (every page's full text)
Search index/blume-search.json
JSON API/api/docs/pages.json, /api/docs/pages/onboarding.json, /api/docs/navigation.json, /api/docs/search
Discovery files/openapi.json, /agent-readability.json, /.well-known/ai-catalog.json, /.well-known/mcp.json, /sitemap.xml, /robots.txt
Social cards/og/onboarding.png
Missing pages/404.md
MCP serverPOST /mcp
Assistant, when onPOST /api/ask

Save this script as check-access.sh. It requests each surface on the custom domain and the workers.dev hostname without following redirects, and prints the status, whether content came back, and whether the canary was in it.

#!/usr/bin/env bash
# Request every surface of the handbook on every hostname it could answer on.
#   ./check-access.sh         signed out: both columns must show "-"
#   ./check-access.sh token   with the service token from the environment

CANARY="HANDBOOK-CANARY-4417"
HOSTS=(
  "https://handbook.acme.example"
  "https://acme-handbook.acme.workers.dev"
)
PATHS=(
  "/"
  "/onboarding"
  "/onboarding.md"
  "/onboarding.mdx"
  "/llms.txt"
  "/llms-full.txt"
  "/blume-search.json"
  "/api/docs/pages.json"
  "/api/docs/pages/onboarding.json"
  "/api/docs/navigation.json"
  "/api/docs/search?q=onboarding"
  "/openapi.json"
  "/agent-readability.json"
  "/.well-known/ai-catalog.json"
  "/.well-known/mcp.json"
  "/og/onboarding.png"
  "/sitemap.xml"
  "/robots.txt"
  "/404.md"
)
MCP_CALL='{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_page","arguments":{"route":"/onboarding"}}}'

auth=()
if [ "$1" = "token" ]; then
  auth=(-H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID"
    -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET")
fi
body=$(mktemp)

# Never follow redirects: a followed 302 would report the login page as a 200.
report() {
  label=$1
  shift
  code=$(curl -s -o "$body" -w "%{http_code}" "${auth[@]}" "$@")
  served="-"
  case $code in 2??) served="served" ;; esac
  canary="-"
  if grep -q "$CANARY" "$body"; then canary="canary"; fi
  printf "%s  %-7s %-7s %s\n" "$code" "$served" "$canary" "$label"
}

for host in "${HOSTS[@]}"; do
  for path in "${PATHS[@]}"; do
    report "$host$path" "$host$path"
  done
  report "$host/onboarding (Accept: text/markdown)" \
    -H "Accept: text/markdown" "$host/onboarding"
  report "$host/mcp (get_page)" -X POST "$host/mcp" \
    -H "Content-Type: application/json" \
    -H "Accept: application/json, text/event-stream" \
    --data "$MCP_CALL"
done
rm -f "$body"

Replace acme in the workers.dev hostname with your account's workers.dev subdomain. If you turn preview URLs on later to review changes, add one to HOSTS. Then run it signed out, and again with the token:

chmod +x check-access.sh
./check-access.sh
./check-access.sh token

Signed out, every line must show - in both columns, usually with a 302 to your team's sign-in page. The workers.dev lines may show a Cloudflare error instead, since that hostname is off. Any served or canary is a leak. With the token, the custom domain lines should read 200 served, with canary on the ones that carry the onboarding page's text, which proves the script reaches real content. Only then add your real pages. Run the signed-out check again whenever the Worker's domains or its Access policies change.

Connect MCP clients through Access

Blume's MCP server has no authentication of its own, so a client has to get past Access the way the script does. Worker-level Access doesn't support WebSocket connections, but Blume's endpoint answers plain POST requests with JSON, so that limit doesn't apply.

Clients that can send custom headers can use a service token. In Claude Code, a project's .mcp.json reads the values from the environment, so the secret stays out of the file:

{
  "mcpServers": {
    "acme-handbook": {
      "type": "http",
      "url": "https://handbook.acme.example/mcp",
      "headers": {
        "CF-Access-Client-Id": "${CF_ACCESS_CLIENT_ID}",
        "CF-Access-Client-Secret": "${CF_ACCESS_CLIENT_SECRET}"
      }
    }
  }
}

The Connect to MCP menu on each page copies install commands and links with the URL alone, so add the headers yourself. A service token stands for whoever holds it: Access logs name the token, not a person, and it works until you revoke it. Issue one per person or machine, with a duration, and revoke it at offboarding.

Clients that sign in with OAuth can use Access itself as the OAuth server. Edit the application and turn on Managed OAuth on its Advanced settings tab. Access then answers a request without a session with a 401 that points the client at its sign-in, and the client opens a browser for the person to sign in against the same policy. The client must support RFC 8707. A client that listens on your machine needs Allow localhost clients or Allow loopback clients turned on, and a web-based client needs its redirect URI under Allowed redirect URIs. Try it with your team's clients before relying on it. Once it's on, signed-out rows in the matrix show 401 instead of 302.

Clients that support neither can't connect. A bypass rule that opened /mcp to them would publish the whole handbook through get_page.

What Access can't cover

Access protects what the Worker serves. A few features send your content somewhere else, or need to reach it from outside:

  • A hosted search provider (Algolia, Orama Cloud, Typesense, or Mixedbread) keeps its own copy of your pages. The default Orama index, FlexSearch, and Pagefind are files the Worker serves, so they stay behind Access.
  • The assistant, when you turn it on, sends readers' questions and the page text it retrieves to the model provider you choose.
  • Link previews in Slack or X can't fetch your Open Graph cards, and agents outside your company can't read llms.txt.
  • The Markdown lives in your Git repository, so the repository's visibility decides who can read the source.

If the site doesn't run on a Worker

Access protects hostnames in your Cloudflare zone. When another server holds the files, that server is the origin, and anything that reaches it directly skips Access.

  • Vercel and Netlify give every deploy its own URL on their own domains, which never pass through your zone. Use their protection instead, as Private docs describes.
  • Your own server, like a node() build in a container, should publish through a Cloudflare Tunnel so it needs no open inbound port. Protect its hostname with a self-hosted application: Access controls > Applications > Create new application > Self-hosted and private > Add public hostname. Then turn on Protect with Access in the tunnel's origin settings, so cloudflared rejects requests without a valid Access token. Run the matrix against the hostname, and try the server's own address from outside your network. The self-hosting guide covers the container.

Troubleshooting

The workers.dev hostname still serves the handbook

Check that wrangler.jsonc sets "workers_dev": false. Switching it off in the dashboard alone doesn't last: the next wrangler deploy turns it back on unless the config says otherwise. Worker-level Access with All traffic covers that hostname either way.

Previews ask for sign-in but the custom domain doesn't

The Worker's Access rule is set to Previews only. Change it to All traffic on the Worker's Access tab.

Requests with the service token still get a 302

The token's policy needs the Service Auth action and must be attached to the handbook application. Under any other action, Access sends the request to the sign-in page. Check the header names too, CF-Access-Client-Id and CF-Access-Client-Secret, and that the token hasn't expired.

The deploy can't add the custom domain

Cloudflare won't create a custom domain on a hostname that already has a CNAME record, or in a zone your account doesn't own. Delete the old record for handbook.acme.example in the zone's DNS settings and deploy again.

Next step

Publish your real handbook

Once every signed-out line shows a dash, move your real pages into docs/, deploy again, and rerun the check.

Read the private docs reference

A step here not working for you? Report a broken step.

Keep going.More guides.

Upgrade your docs with Blume.

Install today and ship a production-grade docs site in minutes. Free and open source, forever.

npx blume init