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") whosedist/is deployed as Workers Static Assets.A hand-written Worker as the deploy entry (
main =), with no zfb adapter installed anywhere in the project."src/ index. ts" [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 development fallback, so the repo is runnable locally with no Cloudflare account.Configuring zfb through
zfb.config.jsonrather thanzfb.config.ts— the same options, no TypeScript build step for the config itself.
Tech used
| Piece | What the repo actually uses |
|---|---|
| Framework | zfb 2.3.0 (@takazudo/zfb + @takazudo/zfb-runtime) with Preact ^10.29.1 and preact-render-to-string |
| Config file | zfb.config.json — JSON, not TS: framework: "preact", output: "static", outDir: "dist", publicDir: "public" |
| Styling | Tailwind CSS v4 — "tailwind": { "enabled": true } plus @import "tailwindcss" at the top of styles/ |
| Rendering mode | Pure SSG. No route sets export const prerender = false; the three pages under pages/ render at build time and never at request time |
| Cloudflare surface | Workers Static Assets. wrangler.toml sets main =, [assets].directory = "./dist", binding = "ASSETS" |
| Bindings | ASSETS only. No KV, no D1, no queues, no Workers AI |
| zfb Cloudflare adapter | Not installed. @takazudo/zfb-adapter-cloudflare appears nowhere in package.json |
| Notable dev deps | wrangler 4.85.0, @cloudflare/workers-types — the Worker itself pulls in no runtime dependencies |
The Worker is 160 lines of TypeScript in src/ plus a 47-line cookie helper in src/. That is the entire server side of 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. Even the gate can be run locally: wrangler dev --local after a build runs the real Worker against your built dist/, and the Worker falls back to a hardcoded development password when SITE_PASSWORD is absent.
Needs Cloudflare. Only deployment does:
Worker secret
SITE_PASSWORD— set withpnpm exec wrangler secret put SITE_PASSWORD. It is a Cloudflare-side secret, never awrangler.tomlvar 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_TOKENandCLOUDFLARE_ACCOUNT_ID. The token needs Account · Workers Scripts (Edit), Account Settings (Read), and Zone · Workers Routes (Edit) — the zone permission is not optional, becausewrangler.tomlattaches acustom_domainroute 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
ASSETSbinding is created fromdist/by Cloudflare automatically.
Set SITE_PASSWORD before you share the URL
src/ falls back to a hardcoded development password when the secret is missing, so that local Wrangler runs work with no Cloudflare state. That fallback value is committed in a public example repository — deploying without setting SITE_PASSWORD publishes a gate whose password is a matter of public record.
The live demo is gated, not writable. There is no form that stores anything, no database, and no per-user state. The gate hands out one fixed marker cookie to everyone who knows the shared password — read the caution at the end of How it works before treating that 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/ 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:
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
[assets]
directory = "./dist"
binding = "ASSETS"
run_worker_first = trueWith 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:
export default {
async fetch(request: Request, env: RuntimeEnv): Promise<Response> {
const url = new URL(request.url);
if (hasValidMarker(request)) {
return 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:
The password comparison is
expectedPassword = env.SITE_PASSWORD || DEV_PASSWORD, compared via SHA-256 digests and a constant-time XOR loop rather than===.The gate's responses carry
Cache-Control: no-store,X-Robots-Tag: noindex, andVary: Cookie, so no cache and no crawler retains a gated response.The marker cookie is
HttpOnlyandSameSite=Lax, andSecureis omitted only for plainhttp:onlocalhost,127.0.0.1, or[::1]. Every other host getsSecure.The
nextvalue is a redirect target supplied by the client, so it is sanitized before use:
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 / (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 password can enter, and anyone holding the fixed marker cookie stays in until it expires.
Note one consequence the example does not spell out: AUTH_MARKER is a hardcoded constant insrc/, so the cookie the gate checks for is not derived from the password at all. Anyone who reads the public repository can set that cookie by hand and skip the password entirely. Before reusing this pattern anywhere the password actually matters, make the marker unforgeable — sign it with a server-side secret and verify the signature — or drop the cookie in favour of a real session.
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 checkThe 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 --localThen curl - 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/), which runs the same assertions against the live site — or against a local Wrangler with SMOKE_.
Related reading
SSR on a Worker — the adapter-emitted
dist/this example deliberately does not use._ worker. js SSR and Cloudflare Bindings — the adapter path, for when a page really does need to render per request.
Static Assets — how
public/files reachdist/, which is what sits behind this gate.Styling — the Tailwind v4 setup this site enables with one config key.
Examples — the rest of the zfb example sites.