Pattern: "sign in with Google" + your own lightweight session

When to reach for this. You want the visitor's real identity and a simple logged-in session — "sign in with Google/GitHub" — but not a full account system with verification flows, password resets, and account linking. A members area, a per-user dashboard, an owner-only admin where the first signed-in email is the owner.

The stance. Own a tiny users/sessions table in the facet's own SQLite. @hono/oauth-providers runs the OAuth dance only — it does not manage sessions or store users; you mint the session and set the cookie. That's the whole pattern: provider login → your sessions table → an opaque cookie. No heavyweight dependency, full control. (Want password login with no provider? The same session table pairs with crypto.subtle PBKDF2 hashing — see the note at the end.)

Three platform things matter more than the code

  • The token exchange is a default-deny egress call. The OAuth callback fetches the provider's token endpoint server-to-server, so the owner must approve that host or it silently fails. Google: config.allowEgress({ host: "oauth2.googleapis.com" }) + config.allowEgress({ host: "www.googleapis.com" }). (GitHub: github.com for the token exchange plus api.github.com for the user/email lookup; X: api.twitter.com for both — x.com is only the browser-side authorize redirect, not egress.) See secrets.
  • redirect_uri is your space's public URL (the site URL get_urls returns — your subdomain or custom domain) and must be registered in the provider's console.
  • 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 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: DurableObjectState, env: Env) {
    super(ctx, env);

    // 1. The OAuth dance. After consent the visitor lands here with their profile.
    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, set an HttpOnly cookie, redirect in.
      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); }

  // --- the session table: opaque token → email, in the facet's own 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;
  }
}

In-facet auth is fully isolated and durable: a production republish preserves accounts + sessions (the facet's SQLite survives the swap).

Owner-only admin, for free

Identity + an authorization check is all "owner-only" is: let the first email to sign in be the owner (or seed the owner row on first boot), then compare the signed-in user in your gated handlers.

Note: password login, no dependency

Want accounts with no social provider and no library? The same sessions table plus a users table with password_hash + password_salt, hashed/verified with crypto.subtle.deriveBits({ name: "PBKDF2", … }). Same isolation and durability. For managed sign-up flows, social linking, OTP, or passkeys, step up to unfolder.auth-accounts.