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.comfor the token exchange plusapi.github.comfor the user/email lookup; X:api.twitter.comfor both —x.comis only the browser-side authorize redirect, not egress.) See secrets. redirect_uriis your space's public URL (thesiteURLget_urlsreturns — 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.