Gating a space by identity (member-only content, owner-only admin)
A deployed facet receives anonymous requests — the platform login lives on
unfolder.space, not on your {slug}.unfolder.space, so you can't read it. To
gate content (a members area, a paywall, an owner-only admin panel) you either
push auth to the edge (Cloudflare Access, Pattern 1) or run your own auth
in-facet (Patterns 2–3), with state in the facet's own SQLite. In-facet auth is
fully isolated: a production redeploy preserves accounts + sessions
(production SQLite survives the swap); a preview branch gets its own empty
auth DB (preview SQLite resets per redeploy).
Three patterns — pick by audience
- Cloudflare Access (Zero Trust) — for an internal / personal space (an admin panel, a private dashboard, a staging site that only you and a few people should reach) that's on a custom domain behind Cloudflare DNS. Cloudflare authenticates the visitor at the edge, before the request reaches your worker; you write no login UI and store no credentials.
- Social login with
@hono/oauth-providers+ a hand-rolled lightweight session — for "sign in with Google/GitHub/…" where you want the visitor's identity and a simple session, but not a full account system. You own a tinyusers/sessionstable; no heavyweight dependency. - better-auth (full account lifecycle) — for end-user-facing auth (memberships, paywalls, public sign-up): managed users, sessions, account linking, plus a plugin ecosystem (social, OTP, passkey, 2FA, organizations). Heavier, but complete.
Patterns 2 and 3 run in-facet with state in the facet's own SQLite (production preserves it; previews reset). All three are below.
Simplest of all — HTTP Basic Auth. For a single shared username/password (a quick gate on
/admin, a staging lock, a one-person tool) you don't need any of the three — Hono's built-inbasicAuthmiddleware is one line and needs no DB or UI. See the section just below; reach for a real pattern once you need per-user accounts.
Pattern 0 — HTTP Basic Auth (one shared password, zero UI)
The browser-native login box, gating whatever routes you mount it on. No
sign-up, no sessions, no storage — one credential checked on every request. Good
for an owner-only /admin, a private staging site, or a tool only you use.
Wire the username as a variable and the password as a secret:
// in run_code, once (owner sets ADMIN_PASSWORD's value in settings, then deploy):
config.setVariable({ name: "ADMIN_USER", value: "admin" })
config.declareSecret({ name: "ADMIN_PASSWORD" })
import { Hono } from "hono";
import { basicAuth } from "hono/basic-auth";
import { DurableObject } from "cloudflare:workers";
export class App extends DurableObject {
app = new Hono();
constructor(ctx: any, env: any) {
super(ctx, env);
// Gate just /admin/*; the public site stays open. Mount on "*" to lock all.
this.app.use("/admin/*", basicAuth({ username: env.ADMIN_USER, password: env.ADMIN_PASSWORD }));
this.app.get("/admin/*", (c) => c.text("secret dashboard"));
this.app.get("/", (c) => c.text("public homepage"));
}
async fetch(request: Request) { return this.app.fetch(request); }
}
basicAuth ships with Hono (no extra dependency). Credentials travel base64 on
every request, so it relies on HTTPS — which every space already has. It's a
shared secret, not per-user identity: there's no "who is logged in", no logout,
and rotating the password means re-deploying. When you need accounts, move up to
Pattern 2 or 3.
Pattern 1 — Cloudflare Access (internal/personal)
If the space already sits on a Cloudflare-managed custom domain
(see custom-domain), protect it with a
Cloudflare Access application in the Zero Trust dashboard (define who may enter —
an email list, your Google/GitHub org, etc.). Cloudflare gates the request at the
edge and forwards a signed JWT; verify it in-worker with
@hono/cloudflare-access
so a request that somehow skips the edge can't slip through.
The middleware needs your team name and the application's AUD tag. Wire them through the space config (see secrets): the team name is non-secret, so a variable; the AUD tag is sensitive, so a secret (the owner pastes its value in settings):
// in run_code, once:
config.setVariable({ name: "CF_ACCESS_TEAM", value: "my-access-team-name" })
config.declareSecret({ name: "CF_ACCESS_AUD" }) // owner sets the value in settings
package.json declares the dep; the facet reads both from env:
{ "name": "my-space", "main": "src/index.ts",
"dependencies": { "hono": "^4.12.18", "@hono/cloudflare-access": "^0.4.0" } }
import { Hono } from "hono";
import { cloudflareAccess } from "@hono/cloudflare-access";
import { DurableObject } from "cloudflare:workers";
export class App extends DurableObject {
app = new Hono();
constructor(ctx: any, env: any) {
super(ctx, env);
// Team name (variable) + AUD tag (secret), both injected into env at deploy.
this.app.use("*", cloudflareAccess(env.CF_ACCESS_TEAM, env.CF_ACCESS_AUD));
this.app.get("/", (c) => c.text("members only"));
}
async fetch(request: Request) {
return this.app.fetch(request);
}
}
(The upstream @hono/cloudflare-access README shows a top-level
export default app; on unfolder a facet must export a named App DO class,
so wrap the Hono app as above — see dynamic-worker.)
Variables/secrets are baked at deploy time, so set them before you deploy.
Pattern 2 — social login with @hono/oauth-providers (lightweight session)
@hono/oauth-providers
runs the OAuth2 dance only — it does not manage sessions or store users. The
middleware (googleAuth, githubAuth, linkedinAuth, xAuth, facebookAuth,
discordAuth, twitchAuth, msEntraAuth) handles the redirect/callback and
hands you the provider profile + token in context; you persist the user and
mint a session. Reach for this when you want "sign in with Google" without
better-auth's full schema.
Three platform-specific things matter more than the code:
- The token exchange is a default-deny outbound call. The callback fetches
the provider's token endpoint server-to-server, so the owner must approve that
host or it silently 403s. For Google:
config.allowEgress({ host: "oauth2.googleapis.com" })andconfig.allowEgress({ host: "www.googleapis.com" })(GitHub:github.com; X:api.x.com; etc.). See secrets. redirect_uriis your space's public URL —{slug}.unfolder.spaceor your custom domain — and must be registered in the provider's console. The middleware defaults the callback to the route it's mounted on.- Credentials map to the config split:
client_id→ a variable,client_secret→ a secret (owner sets the value in settings).
// in run_code, once (then set CF_… values + approve hosts in settings, then deploy):
config.setVariable({ name: "GOOGLE_CLIENT_ID", value: "…apps.googleusercontent.com" })
config.declareSecret({ name: "GOOGLE_CLIENT_SECRET" })
config.allowEgress({ host: "oauth2.googleapis.com" })
config.allowEgress({ host: "www.googleapis.com" })
{ "name": "my-space", "main": "src/index.ts",
"dependencies": { "hono": "^4.12.18", "@hono/oauth-providers": "^0.8.4" } }
import { Hono } from "hono";
import { googleAuth } from "@hono/oauth-providers/google";
import { DurableObject } from "cloudflare:workers";
export class App extends DurableObject {
app = new Hono();
constructor(ctx: any, env: any) {
super(ctx, env);
// 1. The OAuth dance. After consent the visitor lands back here with their
// Google profile in context. redirect_uri defaults to this route's URL.
this.app.use("/auth/google", googleAuth({
client_id: env.GOOGLE_CLIENT_ID, // variable
client_secret: env.GOOGLE_CLIENT_SECRET, // secret
scope: ["openid", "email", "profile"],
}));
this.app.get("/auth/google", (c) => {
const profile = c.get("user-google"); // { id, email, name, … }
// 2. Upsert the user + mint YOUR session (see the session helper below),
// set it as an HttpOnly cookie, then redirect into the app.
const token = this.#startSession(profile!.email!);
c.header("Set-Cookie", `sid=${token}; HttpOnly; Secure; Path=/; SameSite=Lax`);
return c.redirect("/");
});
// 3. Gate everything else on your own session cookie.
this.app.get("*", (c) => {
const email = this.#sessionUser(c.req.header("Cookie"));
if (!email) return c.redirect("/auth/google");
return c.html(`<h1>Welcome ${email}</h1>`);
});
}
async fetch(request: Request) { return this.app.fetch(request); }
// --- lightweight session: opaque token → email, in the facet's SQLite ---
#startSession(email: string): string {
this.ctx.storage.sql.exec(
"CREATE TABLE IF NOT EXISTS sessions (token TEXT PRIMARY KEY, email TEXT NOT NULL, created_at INTEGER NOT NULL)");
const token = crypto.randomUUID() + crypto.randomUUID();
this.ctx.storage.sql.exec(
"INSERT INTO sessions (token, email, created_at) VALUES (?, ?, ?)", token, email, Date.now());
return token;
}
#sessionUser(cookie?: string): string | null {
const sid = cookie?.match(/(?:^|;\s*)sid=([^;]+)/)?.[1];
if (!sid) return null;
const row = this.ctx.storage.sql
.exec("SELECT email FROM sessions WHERE token = ?", sid).toArray()[0];
return (row?.email as string) ?? null;
}
}
That's the whole pattern: provider login → your sessions table → cookie. Add a
users table if you need profile/roles; the first email to sign in can be the
owner (owner-only admin). For password login with no provider at all, the same
session table pairs with PBKDF2 hashing via crypto.subtle (the WebCrypto
recipe — see the note at the end).
Pattern 3 — better-auth (full account lifecycle)
Every facet boots with nodejs_compat, so better-auth's email/password path
(it uses node:crypto scrypt under the hood) runs in-facet. Store its data
through the Drizzle adapter over the facet's durable-sqlite.
package.json:
{
"name": "my-space",
"main": "src/index.ts",
"dependencies": { "better-auth": "1.6.11", "drizzle-orm": "0.45.2" }
}
src/schema.ts — better-auth's four core tables (names/columns are what the
Drizzle adapter expects for provider: "sqlite"):
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const user = sqliteTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: integer("email_verified", { mode: "boolean" }).notNull(),
image: text("image"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});
export const session = sqliteTable("session", {
id: text("id").primaryKey(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
token: text("token").notNull().unique(),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
});
export const account = sqliteTable("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: integer("access_token_expires_at", { mode: "timestamp" }),
refreshTokenExpiresAt: integer("refresh_token_expires_at", { mode: "timestamp" }),
scope: text("scope"),
password: text("password"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});
export const verification = sqliteTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp" }),
updatedAt: integer("updated_at", { mode: "timestamp" }),
});
src/index.ts — wire better-auth, hand it the facet DB, and create the tables
(there's no drizzle-kit in the sandbox, so the facet owns its DDL — idempotent):
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { drizzle } from "drizzle-orm/durable-sqlite";
import { DurableObject } from "cloudflare:workers";
import * as schema from "./schema";
const DDL = [
"CREATE TABLE IF NOT EXISTS user (id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, email_verified INTEGER NOT NULL, image TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)",
"CREATE TABLE IF NOT EXISTS session (id TEXT PRIMARY KEY, expires_at INTEGER NOT NULL, token TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, ip_address TEXT, user_agent TEXT, user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE)",
"CREATE TABLE IF NOT EXISTS account (id TEXT PRIMARY KEY, account_id TEXT NOT NULL, provider_id TEXT NOT NULL, user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, access_token TEXT, refresh_token TEXT, id_token TEXT, access_token_expires_at INTEGER, refresh_token_expires_at INTEGER, scope TEXT, password TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)",
"CREATE TABLE IF NOT EXISTS verification (id TEXT PRIMARY KEY, identifier TEXT NOT NULL, value TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER, updated_at INTEGER)",
];
export class App extends DurableObject {
db = drizzle(this.ctx.storage, { schema });
#secret;
#auth;
constructor(ctx, env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
for (const stmt of DDL) ctx.storage.sql.exec(stmt);
// better-auth signs session cookies, so it needs a *stable* secret. A
// facet has no ambient env bindings, so mint one on first boot and keep
// it in the facet's own KV: stable across production redeploys, and
// regenerated for a preview (which resets anyway). Never commit a secret.
let secret = ctx.storage.kv.get("auth_secret");
if (!secret) {
secret = crypto.randomUUID() + crypto.randomUUID();
ctx.storage.kv.put("auth_secret", secret);
}
this.#secret = secret;
});
}
// baseURL must match the host the request arrived on, so better-auth routes
// its `/api/auth/*` endpoints and its origin checks line up.
#authFor(origin) {
if (!this.#auth) {
this.#auth = betterAuth({
baseURL: origin,
trustedOrigins: [origin],
secret: this.#secret,
database: drizzleAdapter(this.db, { provider: "sqlite" }),
emailAndPassword: { enabled: true },
});
}
return this.#auth;
}
async fetch(request) {
const url = new URL(request.url);
const auth = this.#authFor(url.origin);
// better-auth owns everything under /api/auth/* (sign-up, sign-in, …).
if (url.pathname.startsWith("/api/auth/")) return auth.handler(request);
// Gate the rest on a live session. This is also how you make an
// owner-only admin: check the session, then authorize the user.
const sess = await auth.api.getSession({ headers: request.headers });
if (!sess) return new Response("Sign in required", { status: 401 });
return Response.json({ hello: sess.user.email });
}
}
Clients hit POST /api/auth/sign-up/email and POST /api/auth/sign-in/email
({ email, password, name }), then ride the session cookie.
Social login is a config addition, not a new library. better-auth manages
the OAuth dance, account linking, and token refresh for you — add a
socialProviders block and the same account table holds the linked provider:
betterAuth({
// …database, secret, baseURL as above…
emailAndPassword: { enabled: true },
socialProviders: {
google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET },
github: { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET },
},
});
Same platform rules as Pattern 2 apply: clientId → variable, clientSecret →
secret, and allowEgress the provider token hosts (the callback's token
exchange is default-deny outbound). The broader plugin ecosystem (OTP, passkey,
2FA, organizations, admin) wires in the same way — see the better-auth docs and
its client SDK.
Building block: password login & the WebCrypto session (no dependencies)
The lightweight sessions table from Pattern 2 is all you need for
password login too — no library. Hash with edge-native primitives: a users
table with password_hash + password_salt, the same opaque-token sessions
table, and crypto.subtle.deriveBits({ name: "PBKDF2", … }) to hash/verify.
Same isolation and lifecycle guarantees. Use this when you want accounts but
zero dependencies and no social provider.
Notes
- One auth per space, per surface. Don't mix patterns on the same routes.
- Owner-only admin is just identity + an authorization check: the first account to sign up is the owner, or seed the owner row on first boot, then compare the signed-in user in your gated handlers.
- A declared secret IS injected into
env(soenv.GOOGLE_CLIENT_SECRETworks in Patterns 2 and 3) once the owner sets its value in settings (see secrets). Variables/secrets are baked at deploy time, so set them before deploying. - better-auth's signing secret is different — it's an internal cookie-signing key, not a credential the owner provides. Mint-and-persist it in facet KV on first boot (as shown in Pattern 3), don't commit it.