zfb
GitHub repository

Type to search...

to open search from anywhere

Example: Password Gate

A static zfb site fronted by a hand-written Cloudflare Worker that checks a shared preview password before any asset is served

What this page covers

A small static preview site — three pages, no dynamic routes — that a casual visitor cannot read without a shared password. zfb builds only static assets; a hand-written Cloudflare Worker sits in front of them and asks for the password first.

This is the one example in the family that talks to Cloudflare without@takazudo/zfb-adapter-cloudflare. It is the page to read when you want to know where the adapter ends and a Worker you write yourself begins.

Live demo: zfb-example-password-gate.takazudomodular.com

Repository: Takazudo/zfb-example-password-gate

The live demo answers HTTP 401 on purpose

Opening that URL gets you a login page and a 401 Unauthorized, not the site. That is the demo working, not a broken deploy — the whole point of this example is that an unauthenticated visitor never reaches the content. Its post-deploy smoke test asserts exactly that: a healthy deploy must answer GET / with the 401 login page and never with site content.

What it demonstrates

  • A pure-SSG zfb build (output: "static") whose dist/ is deployed as Workers Static Assets.

  • A hand-written Worker as the deploy entry (main = "src/index.ts"), with no zfb adapter installed anywhere in the project.

  • [assets].run_worker_first = true — the one config line that decides whether the gate actually runs, or is silently bypassed for every path that resolves to a real file.

  • Reading a Cloudflare Worker secret (SITE_PASSWORD), with a localhost-only development fallback and fail-closed behavior on deployed origins where the secret is missing.

  • Configuring zfb through zfb.config.json rather than zfb.config.ts — the same options, no TypeScript build step for the config itself.

Tech used

PieceWhat the repo actually uses
Framework@takazudo/zfb and @takazudo/zfb-runtime 3.1.0, using zudo-react; no Preact or React dependency
Config filezfb.config.json — JSON, not TS: output: "static", outDir: "dist", publicDir: "public", and zudo-wind's owned-v1 reset
StylingAuthored CSS in styles/global.css on top of zudo-wind's owned-v1 reset; no generated utility classes
Rendering modePure SSG. No route sets export const prerender = false; the three pages under pages/ render at build time and never at request time
Cloudflare surfaceWorkers Static Assets. wrangler.toml sets main = "src/index.ts", [assets].directory = "./dist", binding = "ASSETS"
BindingsASSETS only. No KV, no D1, no queues, no Workers AI
zfb Cloudflare adapterNot installed. @takazudo/zfb-adapter-cloudflare appears nowhere in package.json
Notable dev depswrangler 4.85.0, @cloudflare/workers-types 4.20260424.1; the Worker itself pulls in no runtime dependencies

The Worker is in src/index.ts, with cookie parsing and serialization in src/cookies.ts. Those files hold the server-side logic for this example.

Requirements and configuration

Works with no Cloudflare account. pnpm dev, pnpm build, and pnpm preview need nothing but Node and pnpm — they exercise the static site only. The gate can also be run locally: wrangler dev --local after a build runs the real Worker against your built dist/. On plain HTTP loopback origins only, the Worker falls back to a development password when SITE_PASSWORD is absent; on other origins it refuses every login until the secret is set.

Needs Cloudflare. Only deployment does:

  • Worker secret SITE_PASSWORD — set with pnpm exec wrangler secret put SITE_PASSWORD. It is a Cloudflare-side secret, never a wrangler.toml var and never a GitHub secret. Worker secrets take effect immediately and survive later deploys.

  • GitHub Actions secrets for the repo's deploy.yml: CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID. The token needs Account · Workers Scripts (Edit), Account Settings (Read), and Zone · Workers Routes (Edit) — the zone permission is not optional, because wrangler.toml attaches a custom_domain route and creating that route is a zone-level operation.

  • No resources to provision. No KV namespace, no D1 database, no migrations, no seed data. The ASSETS binding is created from dist/ by Cloudflare automatically.

SITE_PASSWORD is required for a usable deployed gate

SITE_PASSWORD must be set as a Cloudflare Worker secret before a deployed visitor can log in. The committed development fallback is accepted only on plain HTTP loopback origins; a deployed origin with an absent or blank secret refuses every login and logs the missing secret.

The live demo is gated, not writable. There is no form that stores anything, no database, and no per-user state. After login, the gate issues a bearer marker cookie derived from the shared password. Read the caution at the end of How it works before treating this shared-password gate as authentication.

How it works

Why hand-write the Worker instead of using the adapter

@takazudo/zfb-adapter-cloudflare exists for the case where a page needs to render at request time: you mark a route export const prerender = false, and zfb emits a dist/_worker.js that routes requests to your page handlers and hands them Cloudflare bindings. See SSR on a Worker for what that emitted Worker looks like inside, and SSR and Cloudflare Bindings for the hands-on setup.

This site needs none of that. Every page is fine as static HTML. What it needs is a decision before the request reaches any page at all — a check that is uniform across every path, including images, CSS, and the 404. That is not page rendering; it is edge middleware, and it has no reason to know that zfb produced the files behind it.

So wrangler.toml points main at hand-written source instead of at an emitted bundle:

wrangler.toml
name = "zfb-example-password-gate"
# The Worker source lives OUTSIDE the assets directory — this is a hand-written
# gate, not an adapter bundle, so `main` is the TS entry, not `dist/_worker.js`.
main = "src/index.ts"
compatibility_date = "2026-05-01"
compatibility_flags = ["nodejs_compat"]

The rule of thumb the example illustrates: reach for the adapter when a page's HTML depends on the request; hand-write a Worker when the request never needs to reach a page. Auth gates, redirects, header rewriting, and geo routing all fall on the hand-written side. They are also mutually compatible — nothing stops an adapter-built site from being fronted by additional Worker logic — but this example keeps the dependency list honest by not installing what it does not use.

run_worker_first is the load-bearing line

wrangler.toml
[assets]
directory = "./dist"
binding = "ASSETS"
# MANDATORY — do not set to false. With the default `false` the asset layer
# answers GET/HEAD before the Worker runs, so any path resolving to a static
# file is served without ever reaching the password gate: a silent bypass that
# still looks protected. `true` forces the Worker first; it serves assets itself
# via env.ASSETS.fetch() once the request is authorized.
# scripts/smoke.mjs asserts a real static asset path is gated, which is what
# catches a regression of this line.
run_worker_first = true

With Workers Static Assets, the asset layer normally answers GET/HEAD for any path that maps to a real file before your Worker code runs at all. That default is what makes a static site fast, and it is exactly wrong here: every path that resolves to a file in dist/ would be served without ever passing the gate.

The failure mode is a silent one: nothing errors, nothing logs, the site just quietly stops being private. Setting run_worker_first = true inverts the order — the Worker runs first and serves assets itself, via env.ASSETS.fetch(request), only after it has authorized the request.

This is also why the repo's smoke test does not merely ask for the site's front page. It reads a real asset path out of dist/ at runtime and asserts that is gated too, so the assertion is anchored to a file that genuinely exists in the deploy rather than to a path whose handling depends on how the asset layer resolves directories.

The gate itself

The whole request path is one fetch handler:

src/index.ts
export default {
  async fetch(request: Request, env: RuntimeEnv): Promise<Response> {
    const url = new URL(request.url);

    if (await hasValidMarker(request, env, url)) {
      return withPrivateCaching(await env.ASSETS.fetch(request));
    }

    if (url.pathname === AUTH_PATH && request.method === "POST") {
      return handleAuth(request, env);
    }

    return loginResponse(sanitizeNext(url.pathname + url.search));
  },
} satisfies ExportedHandler<RuntimeEnv>;

Three branches, in order. A request carrying the marker cookie is proxied straight to the asset layer. A POST /__auth is a login attempt. Everything else — any path, any method — gets the inline login page with 401, and the path it was aiming for is preserved in a hidden next field so a successful login lands where the visitor was going.

Details worth copying:

  • resolveExpectedPassword() accepts a nonblank SITE_PASSWORD; its development fallback is limited to plain HTTP loopback origins. Missing credentials on any other origin fail closed. timingSafeEqual() compares SHA-256 digests with a constant-time XOR loop.

  • Login and error responses carry Cache-Control: no-store, X-Robots-Tag: noindex, and Vary: Cookie. Authorized asset responses also set private no-store caching and vary by cookie, so shared edge caches cannot replay gated content across visitors.

  • hasValidMarker() derives the expected marker from the resolved password with HMAC-SHA256; the marker is not a committed constant and changing the password invalidates existing cookies.

  • The marker cookie is HttpOnly and SameSite=Lax, and Secure is omitted only for plain http: on localhost, 127.0.0.1, or [::1]. Every other host gets Secure.

  • The next value is a redirect target supplied by the client, so it is sanitized before use:

src/index.ts
export function sanitizeNext(value: string): string {
  if (!value.startsWith("/") || value.startsWith("//")) return "/";

  for (const char of value) {
    const code = char.charCodeAt(0);
    if (char === "\\" || code < 0x20 || code === 0x7f) return "/";
  }

  return value;
}

Protocol-relative //evil.example (an open redirect), backslash path tricks, and control characters (header injection via a smuggled newline) all collapse to /.

What this is not

A shared password is not authentication

The repo's own trust model section is blunt about the limits: no users, no sessions, no roles, no audit trail, no logout, no per-person authorization. Anyone with the shared password can enter. The marker is HMAC-SHA256(password, "zfb-preview-gate-marker-v1"), so on deployed origins it cannot be computed from the public source alone; anyone who obtains a valid marker cookie can use it until it expires or the password is rotated. The password also determines the marker, so rotation invalidates existing cookies automatically.

Use Cloudflare Access, an identity provider, or application-level auth for anything genuinely private. As written, this pattern fits low-risk preview sites where the goal is to stop casual discovery, not a determined reader.

Run it locally

pnpm install
pnpm dev        # zfb dev
pnpm build      # zfb build → dist/
pnpm preview    # zfb preview
pnpm typecheck  # zfb check

The caveat, in the repo's own words: pnpm preview uses the zfb static preview server, it does not exercise the Cloudflare Worker gate, and you should use Wrangler for Worker checks after pnpm build.

That matters more here than in the SSR examples, because the thing you are most likely to want to test is the part pnpm preview skips. To exercise the gate, build first and then run Wrangler:

pnpm build
pnpm exec wrangler dev --local

Then curl -i http://localhost:8787/ should return the 401 login page, and a form-encoded POST /__auth with the correct password should return a 302 plus a Set-Cookie. The README lists the full set of manual checks, including the next-sanitization cases above. There is also pnpm smoke (scripts/smoke.mjs), which runs the same assertions against the live site — or against a local Wrangler with SMOKE_URL=http://localhost:8787 pnpm smoke.

  • SSR on a Worker — the adapter-emitted dist/_worker.js this example deliberately does not use.

  • SSR and Cloudflare Bindings — the adapter path, for when a page really does need to render per request.

  • Static Assets — how public/ files reach dist/, which is what sits behind this gate.

  • Styling — authored CSS, zudo-wind resets, and utility styling.

  • Examples — the rest of the zfb example sites.

Revision History

CreatedUpdated