Skip to content

Access gate: dashboard auth is a strategy — Cloudflare Access, a bearer token, or loopback-only none

Everything the dashboard serves — the /runs* pages and streams, /residents*, /costs*, the /mcp/connect/* credential page and the whole command-registry HTTP surface /api/* — sits behind ONE identity gate, asked once per request before any view runs. Which credential the gate checks is a strategy picked by config.yaml's dashboard.auth (plan D5), behind one small interface — verify(req) → identity | refusal (DashboardVerifier, src/channels/dashboardAuth.ts) — so src/index.ts, the tests and any future strategy see the verifier, never the whole Access module (interface segregation). Three strategies:

dashboard.authWhat proves the callerIdentity → actorFor
accessThe Cloudflare Access JWT the edge rule injects in Cf-Access-Jwt-Assertion, re-verified here and failing closed (below)access:<sub> (browser session) or access:svc:<common_name> (service token)A deployment behind a Cloudflare Access application — production
tokenAuthorization: Bearer <token>, compared in constant time against the secret in the env var dashboard.token.env names (default DASHBOARD_TOKEN)The one configured dashboard.token.actor (access:<name>)An Access-free deployment fronted by anything that can add a header — a reverse proxy, curl, a script
noneNothing — so the one rule is WHERE the request comes from: a loopback socket on a localhost deployment (PUBLIC_BASE_URL unset or naming localhost). Every other request is refused; none never serves a remote calleraccess:loopbackA developer running the bot locally

Default when the key is absent: access when ACCESS_TEAM_DOMAIN and ACCESS_AUD are both set, else none. A deployed installation that never configured the dashboard is unchanged: Access when Access is there; without it — a public PUBLIC_BASE_URL — every request was 403 before and is 403 now. The one behavioural change is local development: a loopback caller of a localhost deployment is admitted without setting a variable, where it used to need ACCESS_DEV_BYPASS and was otherwise refused. The one rule is resolveDashboardAuthMode (src/core/dashboardAuthConfig.ts), shared with the capabilities value (src/core/capabilities.ts) so capabilities.dashboardAuth names the same strategy that gates requests. A strategy whose inputs are missing is a startup error naming the pieceaccess without both ACCESS_*, token without its env var or a usable actor, an explicit none on a deployment whose PUBLIC_BASE_URL is public (it would refuse every request — better said once at boot than one 403 at a time).

We do not trust the edge alone. Under access the bot runs on a custom domain; a Cloudflare Access edge rule on the dashboard paths authenticates the user and injects a signed RS256 JWT. This module re-verifies that identity in our own code and fails closed, so the dashboard refuses to serve without a valid Access JWT — even if the edge rule is ever misconfigured, removed, or a client reaches the origin directly and spoofs the header.

This is the identity gate only. It runs FIRST; the live-view handler still applies its existing per-run capability-token check afterward (live-view.md) — defense in depth — and, for the /runs index and the tokenless finished-run routes, decides with the verified identity resolved into the same Actor the /api/* adapter uses (accessActor in src/channels/commandHttp.ts, handed in as ctx.actor): the strategy says who may reach the page, the policy table says which runs it shows (authorization.md items 5–7). /api/* is recognized by the ONE predicate isCommandPath from src/channels/commandHttp.ts (never a second prefix list; percent-encoded, doubled-slash, and trailing-slash spellings all count). Under access the Cloudflare Access application must cover /api/* before the bot deploy that serves it. Non-gated paths (/ingress, /mcp, the health probe) are unaffected.

Two identity shapes. A browser session's JWT has a non-empty sub (+ email) → { sub, email? }. A Cloudflare Access service token (the machine credential for /api/*, presented as CF-Access-Client-Id/-Secret and exchanged by the edge for a JWT) carries an EMPTY sub and a non-empty common_name{ sub: "", commonName }; isServiceToken(identity) tells them apart. accessActor — the one resolver for every surface the gate fronts, /api/* and the /runs pages — maps them to the actors access:<sub> and access:svc:<common_name> respectively (command-registry.md item 14, authorization.md item 9). The token and none strategies produce the browser shape ({ sub }), so their actors are access:<name> and access:loopback, granted like any other browser session (authorization.md: every group's read, plus the id's grants entry). A service token is a command-surface credential only: serviceTokenAllowed admits it on /api/* and refuses it (403) on every page the gate fronts.

Environment

VarMeaning
ACCESS_TEAM_DOMAINBare Access team host, e.g. acme.cloudflareaccess.com. Defensively normalized (scheme + trailing slash stripped, trimmed). Sets iss (https://<team-domain>) and the JWKS URL (https://<team-domain>/cdn-cgi/access/certs).
ACCESS_AUDThe Access application's AUD tag; the token's aud (string or array) must include it.
DASHBOARD_TOKEN (or the var dashboard.token.env names)The token strategy's bearer. Compared in constant time; a match resolves to dashboard.token.actor; never logged. On Cloudflare the bot Worker forwards DASHBOARD_TOKEN into the container — another name must join its forward list.
PUBLIC_BASE_URLUnder none, decides whether the deployment is localhost: unset, or a localhost / 127.0.0.1 / [::1] host on any port. A public host — or a malformed value, which is "not localhost", never a boot crash — refuses every request, and an explicit none there is a startup error.

Both ACCESS_TEAM_DOMAIN and ACCESS_AUD must be set and non-blank for Access to be considered configured; otherwise the config is null — which selects none when dashboard.auth is absent and is a startup error when it is access. ACCESS_DEV_BYPASS is no longer read: none is what it used to grant, and none is the default without Access; a set variable draws one warning at startup. The bypass identity was access:dev-bypass; the none strategy's is access:loopback, so a grants entry keyed by the old id is dead and belongs under the new one (the same warning says so).

Behavior

  1. One gate, one strategy. buildDashboardVerifier composes exactly one DashboardVerifier at startup from config.dashboard, parseAccessConfig(env), the environment and PUBLIC_BASE_URL; src/index.ts calls verify(req) before every dashboard route and writes a refusal's status and body as-is. Nothing downstream asks again who the caller is: the /api/* handler and the history reads that once carried their own loopback rule carry none — the strategy decided before they ran.
  2. access: RS256 verification against the live JWKS. verifyAccessJwt splits the compact JWS, base64url-decodes the header + payload, looks up the signing key by header kid, and verifies the signature over the exact header.payload bytes with crypto.verify("RSA-SHA256", …) using crypto.createPublicKey({ format: "jwk", key }). A bad/absent signature → null403 forbidden. A missing header is the same 403.
  3. Algorithm-confusion defense (critical). The header alg MUST be RS256; none, HS256, or anything else is rejected before any signature work, and verification is always RSA-SHA256 — never an algorithm named by the attacker-controlled header. This is red-verified: a token bearing a genuine RS256 signature but a lying alg header is accepted only if the guard is removed.
  4. Spoofed-header defense. Because we re-verify the JWT at the origin, a client that reaches the origin directly and sets Cf-Access-Jwt-Assertion to a forged, expired, wrong-aud/iss, or differently-signed token is rejected (403). The header is trusted only after cryptographic + claim validation.
  5. Claim validation. iss must equal https://<team-domain>; aud (string or array) must include ACCESS_AUD; exp must be present and in the future; nbf/iat, if present, must not be in the future beyond a 60s skew. The token must then name a subject: a non-empty sub (browser session → { sub, email? }), or an empty/absent sub with a non-empty string common_name (service token → { sub: "", commonName }). An empty sub without a common_name, or with an empty or non-string one, is refused. A browser token that also carries common_name stays a browser identity.
  6. JWKS seam + caching. The JWKS fetch is injectable (JwksFetcher): the real httpJwksFetcher uses global fetch; tests inject a fake (the ≥2-implementations invariant). Keys are cached by kid with a TTL (default 3600s) in a shared JwksCache; an unknown kid triggers exactly one refetch per verify (handles key rotation), and a genuinely unknown kid or a failed fetch fails closed (null).
  7. Never throws on bad input. Any malformed token — wrong segment count, non-base64url, non-JSON header/payload, missing kid, empty — yields null, not an exception. In src/index.ts the async gate's .catch is a 403, never a 500 that would serve the page.
  8. token: a constant-time bearer. Authorization: Bearer <token> (scheme case-insensitive, surrounding whitespace tolerated) is hashed and compared with timingSafeEqual against the env var's value, so neither the length nor a matching prefix leaks; a match is the browser-shaped identity { sub: <name> } for dashboard.token.actor: access:<name>; no header, another scheme, a wrong, partial or empty token is 403 forbidden. The socket address is irrelevant. A bare browser cannot send the header — this strategy is for a proxy, curl or a script in front of an Access-free deployment.
  9. none: loopback on a localhost deployment, or nothing. The identity is { sub: "loopback" } (actor access:loopback) for a request on a loopback socket (127.0.0.1, ::1, ::ffff:127.0.0.1) of a localhost deployment; every other request — a remote socket, or any socket when PUBLIC_BASE_URL is public or malformed — is 403 with a body that says why. Live token pages are no exception: under none the whole dashboard is local.
  10. Selection and fail-fast. An explicit dashboard.auth wins; absent → access if parseAccessConfig(env) is non-null, else none. access without both ACCESS_*, token without its env var or without a dashboard.token.actor of the form access:<name> (a service-token id or another namespace is refused), and an explicit none with a public PUBLIC_BASE_URL are startup errors naming the missing piece; the implicit none never throws. The dashboard block itself is validated at load — a mapping, auth one of the three words (a typo is refused, never read as none), token.env a non-blank string, token.actor an access:<name>, and token.actor required when auth is token.
  11. Startup log states the strategy. The HTTP server logs dashboard auth: access (Cloudflare Access SSO, <team-domain>), dashboard auth: token (bearer from $<ENV> → access:<name>), or dashboard auth: none (no credential — loopback callers on a localhost deployment only) — never a secret.

Validation criteria

CriterionEvidence
Valid RS256 token → { sub, email }; email omitted → { sub, email: undefined }[unit] src/channels/accessAuth.test.ts::verifyAccessJwt::accepts a valid RS256 token and returns { sub, email }, ::returns { sub } with email undefined when the email claim is absent
Expired or missing exp → null[unit] ::verifyAccessJwt::rejects an expired token (exp in the past), ::rejects a token whose exp claim is missing
aud mismatch (string + array) → null; array containing the AUD → ok[unit] ::verifyAccessJwt::rejects an aud mismatch (string and array forms), ::accepts aud as an array that includes the configured AUD
iss mismatch → null; future nbf beyond skew → null[unit] ::verifyAccessJwt::rejects an iss mismatch, ::rejects a token whose nbf is in the future beyond skew
Signature integrity: tampered signature, altered payload, attacker key → null (red-verified: skipping signature verification flips all three)[unit] ::verifyAccessJwt::rejects a token with a tampered signature, ::rejects a token whose payload was altered after signing, ::rejects a token signed by a different (attacker) key
Algorithm confusion: alg:none (empty sig), HS256 HMAC-forged with the public key, and — red-verifiedalg:none/HS256 carrying a genuine RS256 signature → null (removing the alg guard flips the last two + the gate's spoof test)[unit] src/channels/accessAuth.test.ts::verifyAccessJwt::rejects alg:none with an empty signature, ::rejects alg:none even when a valid RS256 signature is attached (alg guard, red-verifiable), ::rejects an HS256 token forged with the public key as the HMAC secret, ::rejects alg:HS256 even when a valid RS256 signature is attached (alg guard, red-verifiable); the access strategy turns that null into 403 forbidden (src/channels/dashboardAuth.test.ts::accessVerifier — the Cloudflare Access strategy::refuses a missing header, a malformed token and an expired one with 403 (fail closed))
Malformed/empty tokens and non-JSON header never throw → null[unit] ::verifyAccessJwt::returns null (never throws) for malformed or empty tokens, ::returns null when the header base64 decodes to non-JSON
kid resolution: missing kid → null; unknown kid → null after exactly one refetch[unit] ::verifyAccessJwt::rejects a token whose header has no kid, ::returns null for an unknown kid after exactly one refetch attempt
JWKS caching: fetcher called once across repeated verifies of the same kid; unknown kid → one refetch then verifies (rotation); certs URL is the team domain's cdn-cgi path; TTL expiry re-fetches[unit] ::verifyAccessJwt::caches JWKS by kid: the fetcher is called once across repeated verifies of the same kid, ::an unknown kid triggers exactly one refetch, then verifies (key rotation), ::requests the JWKS from the team domain's cdn-cgi certs URL; ::JwksCache::re-fetches once a cached key has passed its TTL
Fail-closed on JWKS fetch failure → null[unit] ::verifyAccessJwt::fails closed (null) when the JWKS fetch throws
parseAccessConfig: both vars → config; scheme/slash/whitespace normalized; either missing/blank → null[unit] ::parseAccessConfig::*
access strategy (items 1–2): a valid Cf-Access-Jwt-Assertion → its identity, from any socket; a missing header, a malformed token or an expired one → 403 forbidden; a service token → the service identity; the description names the team domain, never a token[unit] src/channels/dashboardAuth.test.ts::accessVerifier — the Cloudflare Access strategy::*
token strategy (item 8): Authorization: Bearer <token> (scheme case-insensitive, whitespace tolerated) → the configured subject, from any socket; no header, another scheme, a wrong token, a prefix or superstring of it, or an empty bearer → 403 forbidden; the description names the env var and the actor, never the token[unit] src/channels/dashboardAuth.test.ts::tokenVerifier — a bearer resolving to one configured actor::*
none strategy (item 9): a loopback socket (v4, v6, mapped) on a localhost deployment → access:loopback; a non-loopback socket → 403 with the reason, whatever headers it carries; a public or malformed PUBLIC_BASE_URL refuses everyone, loopback included; isLoopbackAddress / isLocalhostBase are the two predicates[unit] src/channels/dashboardAuth.test.ts::loopbackVerifier…::*
Selection (item 10): an explicit mode wins; absent → access iff Access is configured, else none; tokenSubjectOf accepts exactly access:<name>[unit] src/core/dashboardAuthConfig.test.ts::resolveDashboardAuthMode — the default-selection rule::*, ::tokenSubjectOf — the actor id a bearer resolves to::*
Composition fails fast by name (item 10): no key + no ACCESS_*none, no key + ACCESS_*access; access without ACCESS_*, token without its env var (default DASHBOARD_TOKEN, or the named one) or without an access:<name> actor, and an explicit none on a public PUBLIC_BASE_URL throw naming the piece; the implicit none never throws; token reads the named env var and resolves to the configured actor[unit] src/channels/dashboardAuth.test.ts::buildDashboardVerifier — composing the configured strategy, failing fast by name::*
The dashboard block is validated at load (item 10): a non-mapping, an unknown key, a misspelled mode, a bad token block and auth: token without an actor are refused naming the key; each valid shape accepted and exposed as written[unit] src/core/dashboardAuthConfig.test.ts::validateDashboardConfig…::*, src/config.test.ts::dashboard::*
Service tokens: empty/absent sub + non-empty common_name{ sub:"", commonName } and isServiceToken true; empty sub without / with an empty or non-string common_name → null; browser token with common_name stays { sub, email }; expired service token → null; the access strategy admits a service-token header as the service identity[unit] src/channels/accessAuth.test.ts::verifyAccessJwt — Cloudflare Access service tokens::*, src/channels/dashboardAuth.test.ts::accessVerifier — the Cloudflare Access strategy::admits a service token…
/api/* sits behind the same gate via isCommandPath (every spelling); the handler itself carries no reachability rule (item 1) — the strategy decided before it ran[unit] src/channels/commandHttp.test.ts::isCommandPath (the ONE gate predicate …)::*, src/channels/dashboardAuth.test.ts::loopbackVerifier…::*
Live end-to-end under access: with ACCESS_* set and a Cloudflare Access rule on the dashboard paths, an SSO'd browser opens the live-view link; a direct origin request with a forged/absent Cf-Access-Jwt-Assertion gets 403; with ACCESS_* unset and no dashboard.auth, the strategy is none and every remote request gets 403 with the loopback reason; the startup log names the strategy (item 11)[agent] Requires the bot deployed on the custom domain with the Access edge rule in place and ACCESS_TEAM_DOMAIN/ACCESS_AUD set; the none half is a deploy without them.

src/index.ts route wiring (the gate dispatch, the startup-log strategy line) has no unit harness in this repo — consistent with how liveView/ingress/mcp wiring is left to the handler unit tests. The gate's decision logic lives entirely in buildDashboardVerifier + the three verifiers + resolveDashboardAuthMode + parseAccessConfig, which are covered above.