OAuth Implementation Checklist
Better Auth + Drizzle + Hono + Cloudflare Worker. Every step is a checkbox. Do not skip any. Do them in order.
1. Rules
- Never write auth SQL by hand
- Always generate auth schema with Better Auth CLI first
- Always generate SQL migrations with Drizzle Kit second
- Always apply migrations with Drizzle Kit third
- Keep local DB URL in
.dev.localasLOCAL_DATABASE_URL(single source of truth) - Keep
dev,drizzle.config.ts,drizzle.local.config.ts, andsrc/lib/auth.tsaligned to.dev.local
2. Install Dependencies
- Install runtime dependencies:
bun add better-auth drizzle-orm hono pg dotenv- Install dev dependencies:
bun add -d drizzle-kit wrangler @types/pg- Confirm
package.jsonhas these independencies:-
better-auth -
drizzle-orm -
hono -
pg -
dotenv
-
- Confirm
package.jsonhas these indevDependencies:-
drizzle-kit -
wrangler
-
3. Create Local Environment Files
- Create
.dev.localwith exactly:
LOCAL_DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:5432/<ProjectName>"- Create
.dev.varswith:
BASE_URL=http://localhost:<SitePort>
BETTER_AUTH_SECRET=<generate-a-long-random-secret>
TWITTER_CLIENT_ID=<your-twitter-client-id>
TWITTER_CLIENT_SECRET=<your-twitter-client-secret>
ALLOWED_ORIGINS=http://localhost:<SitePort>- Confirm
.dev.localis in.gitignore - Confirm
.dev.varsis in.gitignore
4. Create The Worker Env Type
- Create
src/types/env.ts:
export type AppEnv = {
Bindings: {
ASSETS: {
fetch(request: Request): Promise<Response>;
};
HYPERDRIVE: {
connectionString: string;
};
ALLOWED_ORIGINS?: string;
BASE_URL: string;
BETTER_AUTH_SECRET?: string;
SESSION_SIGNING_KEY?: string;
TWITTER_CLIENT_ID: string;
TWITTER_CLIENT_SECRET: string;
};
};- Confirm every auth-related binding is present:
-
HYPERDRIVE(DB connection via Cloudflare Hyperdrive) -
BASE_URL(origin for callback URL construction) -
BETTER_AUTH_SECRET(session signing) -
SESSION_SIGNING_KEY(fallback session signing) -
TWITTER_CLIENT_ID -
TWITTER_CLIENT_SECRET -
ALLOWED_ORIGINS(optional, comma-separated trusted origins)
-
5. Create The Better Auth CLI Config
This file is ONLY used by the Better Auth CLI for schema generation. It is NOT the runtime config.
- Create
src/lib/auth.ts:
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { config } from "dotenv";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "../db/auth-schema";
config({ path: ".dev.local" });
const connectionString =
process.env["LOCAL_DATABASE_URL"] ??
"postgresql://postgres:postgres@127.0.0.1:5432/<ProjectName>";
const dbClient = new Pool({ connectionString });
const db = drizzle(dbClient, { schema });
export const auth = betterAuth({
baseURL: process.env["BASE_URL"] ?? "http://localhost:<SitePort>",
basePath: "/api/auth",
secret:
process.env["BETTER_AUTH_SECRET"] ??
process.env["SESSION_SIGNING_KEY"] ??
"replace-me-before-production",
database: drizzleAdapter(db, {
provider: "pg",
schema,
}),
socialProviders: {
twitter: {
clientId: process.env["TWITTER_CLIENT_ID"] ?? "",
clientSecret: process.env["TWITTER_CLIENT_SECRET"] ?? "",
},
},
trustedOrigins: [
process.env["BASE_URL"] ?? "http://localhost:<SitePort>",
"http://localhost:<SitePort>",
],
advanced: {
useSecureCookies: true,
},
});- Confirm it reads
LOCAL_DATABASE_URLfrom.dev.local - Confirm
basePathis/api/auth - Confirm
socialProviders.twitteris configured - Confirm it imports
* as schema from "../db/auth-schema"
6. Create App User Table
Your app needs a table to map the provider's account id to your own user identity.
- Create
src/db/schema.tswith at minimum an app user table:
import { pgTable, text } from "drizzle-orm/pg-core";
export const appUser = pgTable("app_user", {
xId: text("x_id").primaryKey(),
mashId: text("mash_id"),
});- Confirm
app_userhasx_idas primary key (this will hold the Twitteraccount_idfrom the Better Authaccounttable)
7. Add package.json Scripts
- Add these auth-related scripts to
package.json:
{
"scripts": {
"auth:schema": "bunx @better-auth/cli@latest generate --config ./src/lib/auth.ts --output ./src/db/auth-schema.ts --yes",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:migrate:local": "drizzle-kit migrate --config drizzle.local.config.ts",
"db:migrate:remote": "drizzle-kit migrate --config drizzle.remote.config.ts"
}
}- Confirm
auth:schemapoints--configat./src/lib/auth.ts - Confirm
auth:schemapoints--outputat./src/db/auth-schema.ts
8. Configure Drizzle Kit
- Create
drizzle.config.ts:
import { defineConfig } from "drizzle-kit";
import { config } from "dotenv";
config({ path: ".dev.local" });
const connectionString =
process.env.LOCAL_DATABASE_URL ??
"postgresql://postgres:postgres@127.0.0.1:5432/<ProjectName>";
if (!connectionString) {
throw new Error("Set LOCAL_DATABASE_URL before running Drizzle commands.");
}
export default defineConfig({
out: "./drizzle/migrations",
schema: ["./src/db/schema.ts", "./src/db/auth-schema.ts"],
dialect: "postgresql",
dbCredentials: {
url: connectionString,
},
});- Create
drizzle.local.config.ts(identical but without the throw guard):
import { defineConfig } from "drizzle-kit";
import { config } from "dotenv";
config({ path: ".dev.local" });
const connectionString =
process.env.LOCAL_DATABASE_URL ??
"postgresql://postgres:postgres@127.0.0.1:5432/<ProjectName>";
export default defineConfig({
out: "./drizzle/migrations",
schema: ["./src/db/schema.ts", "./src/db/auth-schema.ts"],
dialect: "postgresql",
dbCredentials: {
url: connectionString,
},
});- Confirm both configs include BOTH schema files in the
schemaarray:-
./src/db/schema.ts -
./src/db/auth-schema.ts
-
9. Generate Auth Schema And Migrate
- Start local postgres:
bun run db:local- Generate Better Auth table definitions into
src/db/auth-schema.ts:
bun run auth:schema- Confirm
src/db/auth-schema.tswas generated and contains these tables:-
user(id, name, email, emailVerified, image, createdAt, updatedAt) -
session(id, expiresAt, token, createdAt, updatedAt, ipAddress, userAgent, userId) -
account(id, accountId, providerId, userId, accessToken, refreshToken, idToken, accessTokenExpiresAt, refreshTokenExpiresAt, scope, password, createdAt, updatedAt) -
verification(id, identifier, value, expiresAt, createdAt, updatedAt)
-
- Confirm relations were generated:
-
userRelations(has many sessions, has many accounts) -
sessionRelations(belongs to user) -
accountRelations(belongs to user)
-
- Generate SQL migration:
bun run db:generate- Confirm migration files appeared in
drizzle/migrations/ - Apply migration locally:
bun run db:migrate:local- Confirm migration applied without errors
- If you change Better Auth config or plugins later, re-run
auth:schemaTHENdb:generateTHENdb:migrate:local
10. Implement Runtime Auth Factory
This is the runtime config used by the actual Worker. Different from the CLI config in Step 5.
- Create
src/auth/runtime.ts:
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "../db/auth-schema";
import type { AppEnv } from "../types/env";
type AuthBindings = AppEnv["Bindings"];
const DEFAULT_BASE_URL = "http://localhost:<SitePort>";
const LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]);
function parseCSV(value?: string): string[] {
if (!value) {
return [];
}
return value
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function normalizeBaseURL(input: string): string | null {
try {
const parsed = new URL(input);
if (LOCAL_HOSTNAMES.has(parsed.hostname) && parsed.protocol === "https:") {
parsed.protocol = "http:";
}
return parsed.origin;
} catch {
return null;
}
}
function resolveBaseURL(bindings: AuthBindings, requestURL?: string): string {
const configuredBaseURL = bindings.BASE_URL?.trim();
if (configuredBaseURL) {
const normalizedConfiguredBaseURL = normalizeBaseURL(configuredBaseURL);
if (normalizedConfiguredBaseURL) {
return normalizedConfiguredBaseURL;
}
}
if (requestURL) {
const normalizedRequestBaseURL = normalizeBaseURL(requestURL);
if (normalizedRequestBaseURL) {
return normalizedRequestBaseURL;
}
}
return DEFAULT_BASE_URL;
}
function resolveSecret(bindings: AuthBindings): string {
return (
bindings.BETTER_AUTH_SECRET?.trim() ||
bindings.SESSION_SIGNING_KEY?.trim() ||
"replace-me-before-production"
);
}
function resolveTrustedOrigins(
bindings: AuthBindings,
baseURL: string,
): string[] {
return Array.from(
new Set([
...parseCSV(bindings.ALLOWED_ORIGINS),
bindings.BASE_URL?.trim() ?? "",
baseURL,
"<ProductionURL>",
DEFAULT_BASE_URL,
]),
).filter((origin) => origin.length > 0);
}
export function createRequestAuth(
bindings: AuthBindings,
requestURL?: string,
) {
const baseURL = resolveBaseURL(bindings, requestURL);
const useSecureCookies = baseURL.startsWith("https://");
const dbClient = new Pool({
connectionString: bindings.HYPERDRIVE.connectionString,
});
const db = drizzle(dbClient, { schema });
return betterAuth({
baseURL,
basePath: "/api/auth",
secret: resolveSecret(bindings),
database: drizzleAdapter(db, {
provider: "pg",
schema,
}),
socialProviders: {
twitter: {
clientId: bindings.TWITTER_CLIENT_ID,
clientSecret: bindings.TWITTER_CLIENT_SECRET,
disableDefaultScope: true,
scope: ["users.read", "tweet.read", "offline.access"],
},
},
trustedOrigins: resolveTrustedOrigins(bindings, baseURL),
advanced: {
useSecureCookies,
},
});
}- Confirm key differences from CLI config (Step 5):
- Reads DB from
bindings.HYPERDRIVE.connectionString(notLOCAL_DATABASE_URL) - Sets
useSecureCookiesdynamically based on HTTPS (not hardcodedtrue) - Sets
disableDefaultScope: trueand explicit scopes on twitter provider - Builds trusted origins from
ALLOWED_ORIGINSenv var + computed values - Secret resolves from bindings, not
process.env
- Reads DB from
11. Implement Auth Routes
- Create
src/routes/auth.ts:
import { Hono, type Context } from "hono";
import { and, eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/node-postgres";
import { Client } from "pg";
import { createRequestAuth } from "../auth/runtime";
import { account } from "../db/auth-schema";
import { appUser } from "../db/schema";
import type { AppEnv } from "../types/env";
type MeResponse = {
name: string;
profile: string | null;
x_id: string;
mash_id: string | null;
};
function appendSetCookieHeaders(source: Headers, target: Headers): void {
for (const [name, value] of source.entries()) {
if (name.toLowerCase() === "set-cookie") {
target.append("set-cookie", value);
}
}
}
export async function loginTwitter(c: Context<AppEnv>): Promise<Response> {
const callbackURL = "/";
const auth = createRequestAuth(c.env, c.req.url);
const authResponse = await auth.api.signInSocial({
body: {
provider: "twitter",
callbackURL,
},
headers: c.req.raw.headers,
asResponse: true,
});
const locationHeader = authResponse.headers.get("location");
let redirectURL = locationHeader;
if (!redirectURL) {
const payload = (await authResponse.clone().json().catch(() => null)) as {
url?: string;
} | null;
redirectURL = payload?.url ?? null;
}
if (!redirectURL) {
return authResponse;
}
const headers = new Headers({ location: redirectURL });
appendSetCookieHeaders(authResponse.headers, headers);
return new Response(null, {
status: 302,
headers,
});
}
export async function getMe(c: Context<AppEnv>): Promise<Response> {
const auth = createRequestAuth(c.env, c.req.url);
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});
if (!session?.user) {
return c.json({ error: "Unauthorized" }, 401);
}
const client = new Client({
connectionString: c.env.HYPERDRIVE.connectionString,
});
await client.connect();
try {
const db = drizzle(client, { schema: { account, appUser } });
const twitterAccount = await db.query.account.findFirst({
where: (fields) =>
and(
eq(fields.userId, session.user.id),
eq(fields.providerId, "twitter"),
),
columns: {
accountId: true,
},
});
if (!twitterAccount) {
return c.json({ error: "Twitter account not linked" }, 403);
}
const existingAppUser = await db.query.appUser.findFirst({
where: (fields) => eq(fields.xId, twitterAccount.accountId),
});
const resolvedAppUser =
existingAppUser ??
(
await db
.insert(appUser)
.values({
xId: twitterAccount.accountId,
})
.returning({
xId: appUser.xId,
mashId: appUser.mashId,
})
)[0];
if (!resolvedAppUser) {
throw new Error("Failed to resolve app user.");
}
const user: MeResponse = {
name: session.user.name,
profile: session.user.image ?? null,
x_id: resolvedAppUser.xId,
mash_id: resolvedAppUser.mashId,
};
return c.json(user);
} finally {
await client.end();
}
}
export async function logout(c: Context<AppEnv>): Promise<Response> {
const auth = createRequestAuth(c.env, c.req.url);
return auth.api.signOut({
headers: c.req.raw.headers,
asResponse: true,
});
}
async function forwardBetterAuth(c: Context<AppEnv>): Promise<Response> {
return createRequestAuth(c.env, c.req.url).handler(c.req.raw);
}
export const authRoutes = new Hono<AppEnv>()
.get("/login/twitter", loginTwitter)
.get("/callback/:provider", forwardBetterAuth)
.get("/me", getMe)
.post("/logout", logout)
.on(["GET", "POST"], "/*", forwardBetterAuth);
export function registerAuthRoutes(app: Hono<AppEnv>) {
return app.route("/api/auth", authRoutes);
}- Confirm these 5 routes exist and are in this exact order:
-
GET /login/twitter->loginTwitter -
GET /callback/:provider->forwardBetterAuth -
GET /me->getMe -
POST /logout->logout -
GET|POST /*->forwardBetterAuth(catch-all, must be last)
-
- Confirm
loginTwitterdoes all three of these:- Calls
auth.api.signInSocialwithprovider: "twitter"andcallbackURL: "/" - Extracts redirect URL from
locationheader OR JSON bodyurlfield - Copies all
set-cookieheaders from Better Auth response into the final 302
- Calls
- Confirm
getMedoes all of these in order:- Calls
auth.api.getSession({ headers }) - Returns 401 if no session
- Queries
accountwhereuserId = session.user.idANDproviderId = "twitter" - Returns 403 if no twitter account linked
- Finds or creates
app_userrow byxId = account.accountId - Returns
{ name, profile, x_id, mash_id }
- Calls
- Confirm
logoutcallsauth.api.signOut({ headers, asResponse: true }) - Confirm catch-all forwards to
createRequestAuth(...).handler(c.req.raw)
12. Mount Auth Routes In App Entrypoint
- In your
src/index.ts, register auth routes:
import { Hono } from "hono";
import { registerAuthRoutes } from "./routes/auth";
import type { AppEnv } from "./types/env";
const app = registerAuthRoutes(new Hono<AppEnv>());
export default app;- Confirm
registerAuthRoutesmounts auth routes at/api/auth
13. Configure Wrangler
- Create/update
wrangler.jsonc:
{
"name": "<ProjectName>",
"main": "src/index.ts",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"binding": "ASSETS",
"directory": "./dist"
},
"hyperdrive": [
{
"binding": "HYPERDRIVE",
"id": "<your-hyperdrive-id>",
"localConnectionString": "postgresql://postgres:postgres@127.0.0.1:5432/<ProjectName>"
}
]
}- Confirm
nodejs_compatis incompatibility_flags(required forpgmodule) - Confirm
ASSETSbinding points to./dist - Confirm
HYPERDRIVEbinding has a validlocalConnectionString
14. Register Twitter OAuth Callback URLs
- In Twitter/X developer portal, configure OAuth 2.0:
- App type: Web App / Confidential Client
- Add local callback URL exactly:
http://localhost:<SitePort>/api/auth/callback/twitter - Add production callback URL exactly:
<ProductionURL>/api/auth/callback/twitter - Add www callback URL (if needed):
https://www.tweetmash.com/api/auth/callback/twitter - Scopes:
users.read tweet.read offline.access
- Copy
TWITTER_CLIENT_IDandTWITTER_CLIENT_SECRETinto.dev.vars
15. Generate, Migrate, And Start
- Start local postgres
- Generate auth schema and migrate:
bun run auth:schema
bun run db:generate
bun run db:migrate:local- Start dev server:
wrangler dev- Stop if any command fails
16. Verify Login Flow (local)
16.1 Smoke test (curl)
- Run:
curl -i http://localhost:<SitePort>/api/auth/me- Expect:
401with{"error":"Unauthorized"}
- Expect:
- Run:
curl -i http://localhost:<SitePort>/api/auth/login/twitter- Expect:
302withLocationheader pointing to Twitter - Expect:
Set-Cookieheaders present in response
- Expect:
16.2 Browser test
- Open
http://localhost:<SitePort> - Click
Sign in with X - Confirm redirect chain:
-
GET /api/auth/login/twitter-> 302 to Twitter - Twitter consent page loads
- Twitter redirects to
/api/auth/callback/twitter?code=...&state=... - Better Auth redirects to
/(becausecallbackURLis/)
-
- Confirm UI shows signed-in state (name, profile, x_id, mash_id)
- Click
Log out - Confirm UI shows signed-out state
- Confirm
GET /api/auth/menow returns401
16.3 Database verification
- Check
usertable has a row for the logged-in user - Check
accounttable has a row withprovider_id = 'twitter'and validaccount_id - Check
sessiontable has a row with validtokenandexpires_at - Check
app_usertable has a row withx_idmatchingaccount.account_id
17. Route Contract Reference
| Route | Method | Happy Path | Failure |
|---|---|---|---|
/api/auth/login/twitter |
GET | 302 + Location + Set-Cookie |
Better Auth passthrough if no redirect URL |
/api/auth/callback/twitter |
GET | Better Auth redirect to / |
Better Auth error (state/PKCE mismatch) |
/api/auth/me |
GET | 200 {name, profile, x_id, mash_id} |
401 (no session) or 403 (no twitter link) |
/api/auth/logout |
POST | Better Auth 2xx |
Better Auth managed |
/api/auth/* |
GET/POST | Better Auth managed | Better Auth managed |
18. Session And Security Checklist
- Confirm these Better Auth tables exist in
src/db/auth-schema.ts:-
user -
account -
session -
verification
-
- Confirm session check in
getMeusesauth.api.getSession({ headers: c.req.raw.headers }) - Confirm OAuth state/PKCE is handled by Better Auth callback (no manual state management)
- Confirm
loginTwitterpreservesSet-Cookieheaders viaappendSetCookieHeaders - Confirm
useSecureCookiesis dynamic in runtime (true only when baseURL is HTTPS) - Confirm secret resolution order:
BETTER_AUTH_SECRET->SESSION_SIGNING_KEY-> fallback - Confirm trusted origins are built from:
ALLOWED_ORIGINS+BASE_URL+ resolved baseURL + hardcoded defaults - For production:
- Set
BETTER_AUTH_SECRETto a strong random value (never use fallback) - Set
BASE_URLto exact production origin (e.g.,<ProductionURL>) - Ensure HTTPS so secure cookies are enabled
- Keep
ALLOWED_ORIGINSrestricted to real origins only - Keep callback URLs exact-match in Twitter app settings
- Set
19. Token Access (for calling Twitter API)
The account table stores provider tokens. Current getMe only reads accountId.
- To access tokens, extend the account query columns:
const twitterAccountWithTokens = await db.query.account.findFirst({
where: (fields) =>
and(eq(fields.userId, session.user.id), eq(fields.providerId, "twitter")),
columns: {
accountId: true,
accessToken: true,
refreshToken: true,
accessTokenExpiresAt: true,
refreshTokenExpiresAt: true,
scope: true,
},
});- Available token columns in
accounttable:-
accessToken(text, nullable) -
refreshToken(text, nullable) -
idToken(text, nullable) -
accessTokenExpiresAt(timestamp, nullable) -
refreshTokenExpiresAt(timestamp, nullable) -
scope(text, nullable)
-
- Note: refresh-token rotation is NOT implemented in current app code
20. Production Deploy Checklist (GO_LIVE.md)
- Set Wrangler secrets:
-
wrangler secret put TWITTER_CLIENT_ID -
wrangler secret put TWITTER_CLIENT_SECRET -
wrangler secret put BETTER_AUTH_SECRET -
wrangler secret put SESSION_SIGNING_KEY(optional, fallback)
-
- Set production vars:
-
BASE_URL=<ProductionURL> -
ALLOWED_ORIGINS=<ProductionURL>,https://www.tweetmash.com
-
- Register production callback URLs in Twitter app:
-
<ProductionURL>/api/auth/callback/twitter -
https://www.tweetmash.com/api/auth/callback/twitter(if using www) - Run remote migration:
bun run auth:schema
bun run db:generate
bun run db:migrate:remote- Deploy:
wrangler deploy- Post-deploy verification:
- Open production URL, click Sign in with X
- Confirm redirect to Twitter, back to callback, then to
/ - Confirm
/api/auth/mereturns signed-in identity - Confirm logout works
21. Failure Triage
-
GET /api/auth/login/twitteris not302:- Check
TWITTER_CLIENT_IDandTWITTER_CLIENT_SECRETare set - Check
BASE_URLis set and matches browser origin - Check trusted origins include the request origin
- Check
- Callback fails (state/PKCE error):
- Verify login response includes
Set-Cookieheaders (OAuth state + PKCE verifier cookies) - Verify browser preserves cookies through redirect chain
- Verify callback URL in Twitter app settings matches exactly (scheme, host, port, path)
- Verify login response includes
-
/api/auth/mereturns401after successful login:- Check session cookie is present in browser
- Check cookie domain and protocol match (secure cookies require HTTPS)
- Check
BETTER_AUTH_SECRETis the same value that signed the session
-
/api/auth/mereturns403:- Session exists but no row in
accountwithprovider_id = 'twitter'for that user
- Session exists but no row in
- Origin rejection errors:
- Verify
ALLOWED_ORIGINSandBASE_URLmatch the actual request origin exactly
- Verify