Access in front of static sites

Static documentation generators do not log people in. They emit HTML, CSS, and a search index. Privacy is a hosting job: put an identity-aware reverse proxy on the hostname so unauthenticated requests never see those files.

This is the Dev-Centr pattern for member-only sister sites (and any other static host that must not be world-readable). Task steps: Protect a static site with Cloudflare Access.

Why not an app

A SolidStart (or Next, or Rails) rewrite would add sessions, user tables, and a deploy surface — and you would still put Access or equivalent in front of it. Antora already matches the public hub (Valentus, Lunr, AsciiDoc). Split public hub vs member hub at the playbook; do not split at the framework.

Per-page ACLs inside one site are the exception. Two playbooks (public vs private) are cheaper than app-level auth.

Where the lock sits

Layer Job

Git forge

Source stays in a private repo. That does not protect the built site.

CI

Build on the private repo (GitHub Actions still runs on free-plan private repos). Direct-upload the build/ tree to the host.

Host

Cloudflare Pages, object storage, or any origin. Treat *.pages.dev (and preview URLs) as public unless they are in the same Access policy as the custom domain.

Edge

Cloudflare Access (Zero Trust) on the orange-cloud hostname. SSO cookie; HTML never leaves the edge for strangers.

Identity

GitHub org, Google Workspace / Google accounts, or email one-time PIN. Access does not invent a second password database.

flowchart LR
  visitor[Visitor]
  access[Cloudflare Access]
  idp[IdP: GitHub / Google / OTP]
  origin[Static origin]
  visitor --> access
  access -->|"no session"| idp
  idp -->|"Allow policy"| access
  access -->|"JWT cookie"| origin

Orange-cloud (proxied) DNS is required so Access can intercept the request. Grey-cloud to GitHub Pages cannot do this; that path is for public sites (GitHub Pages custom domain).

How Cloudflare learns GitHub org membership

Cloudflare does not scrape the GitHub org, and it does not keep a member roster of its own. GitHub answers at login. Access then stores that answer in a session cookie until the cookie expires.

How Cloudflare Access learns GitHub org membership: OAuth, GET /user/orgs, Allow or Deny
Figure 1. GitHub answers "are they in the org?" at login

Handshake

  1. You register a GitHub OAuth App (org-owned is best) and paste its Client ID / secret into Zero Trust as the GitHub identity provider. Homepage and callback URLs are the Zero Trust team domain (https://<team-name>.cloudflareaccess.com), not the docs hostname. Mocks: How-to ยท GitHub as IdP.

  2. The first Authorize grants Cloudflare Access read-only organizations and teams plus read-only email. Those are GitHub’s read:org and user:email scopes.

  3. A visitor hits the member hostname. Access has no JWT cookie, so it sends them to GitHub’s login / consent screen.

  4. GitHub authenticates them and (if they approve) hands Access a short-lived token.

  5. Access calls GitHub’s API with that token (GET /user/orgs, and teams when the policy asks) and builds an identity: GitHub user, email, org list, team list.

  6. The Access Allow rule is "login method = GitHub and GitHub organization = your-org`" (optionally a team). If `your-org is in that list, Access sets a JWT cookie and the static HTML loads. If not, deny — the origin never runs.

sequenceDiagram
  participant V as Visitor
  participant A as Cloudflare Access
  participant G as GitHub
  participant O as Static origin
  V->>A: GET https://team.docs...
  A-->>V: Access login (no JWT)
  V->>G: OAuth (orgs + teams, email)
  G-->>A: token
  A->>G: GET /user/orgs
  G-->>A: [your-org, ...]
  alt org matches Allow policy
    A-->>V: JWT cookie
    V->>O: GET (cookie)
    O-->>V: HTML
  else not in org
    A-->>V: deny
  end

GitHub login without an org (or team) rule would let any GitHub user in. The organization selector is the gate. You do not need a GitHub organization to use GitHub as an IdP at all — but then you must gate on email or another selector.

Session vs live membership

Access does not re-query GitHub on every page view. Membership is checked when the person signs in. Kick them from the org and they stay in until the Access session expires, unless you revoke the user under Zero Trust Team & ResourcesUsers.

If someone joins the org after a failed login, GitHub may keep serving the old (empty) org list until they revoke the OAuth app under GitHub SettingsApplications and sign in again. Cloudflare documents this under the GitHub IdP troubleshooting notes.

When GitHub will not tell Access

If the GitHub organization restricts third-party OAuth apps, an org owner must approve this OAuth app or Access cannot see membership — people in the org still look like outsiders. Approve the app under the org’s Third-party access / OAuth app policy, then have readers sign in again.

Authorize is not "pick the Access Allow org." Cloudflare requested read:org, so GitHub shows all orgs on the operator’s (or reader’s) account. An org-owned OAuth App is auto-granted for its owner org. Orgs without OAuth App access restrictions may appear as already granted when any member authorizes the app — that does not put those orgs' members through Access. The Allow policy still matches one org slug. Operator how-to: Other orgs on the Authorize screen.

What agents cannot automate

The GitHub OAuth App is a dashboard object. GitHub’s REST API has no "create OAuth App" route. An agent with a working GitHub connection can list org members and still cannot mint client_id / client_secret. Cloudflare then refuses the GitHub identity provider: missing_client_id_or_client_secret.

IdP first, lock last: GitHub UI OAuth App, then Zero Trust IdP, policy, Access apps
Figure 2. Human step, then API
POST OAuth App does not exist; Cloudflare IdP create needs client_id and client_secret
Figure 3. GitHub OAuth Apps have no create API

A Pages-scoped API token (typical CI secret for wrangler pages deploy) is a second trap. It may list Access applications as an empty array (200) and still return 403 Authentication error on identity providers, organizations, and policies. Access writes need the Global API Key (plus account email) or a token that includes Access Apps/Policies and Identity Providers. In Composio those are two different toolkits: Connect Cloudflare in Composio.

Pages token 403s on identity providers; Global API Key can write Access
Figure 4. Same account, two secrets (redacted statuses)

Do not attach Access applications to live hostnames until the GitHub IdP and Allow policy exist. Applications deny by default.

Creating the GitHub IdP via API is still not the last human step. POST …​/identity_providers can succeed (200) and return messages: "Finish enterprise enablement by going to https://<team-name>.cloudflareaccess.com/cdn-cgi/access/enterprise-setup/<opaque>;." That URL is one-shot and can return Cloudflare error 500 (Internal server error, a Ray ID). A later GET on the IdP does not reissue it. The durable path is the Cloudflare dashboard (Finish setup, then Test), with GitHub already unlocked in the same browser so 2FA does not break the return. Official wording: GitHub identity provider (step "Finish setup").

Redacted API enterprise-setup message and 500 fallback
Figure 5. API enterprise-setup can 500
Identity providers list with Finish setup and Test
Figure 6. Dashboard Finish setup / Test

What Access is not

  • Not Cloudflare account seats (people who edit DNS and Workers). Readers never need the dashboard.

  • Not encryption of the AsciiDoc in git. Do not put secrets in docs; Access is a gate.

  • Not a substitute for keeping the aggregator repo private.