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

  1. 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.
  2. 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 tiny users/sessions table; no heavyweight dependency.
  3. 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-in basicAuth middleware 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" }) and config.allowEgress({ host: "www.googleapis.com" }) (GitHub: github.com; X: api.x.com; etc.). See secrets.
  • redirect_uri is your space's public URL{slug}.unfolder.space or 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 (so env.GOOGLE_CLIENT_SECRET works 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.