Implementation Investigation

Better Auth on Cloudflare Workers with D1, without Drizzle

This report outlines an implementation plan for replacing the current placeholder Better Auth setup with a Cloudflare D1-backed configuration that does not use Drizzle.

Reviewed project files src/lib/auth.ts, src/lib/auth-client.ts, src/worker.ts, wrangler.jsonc, package.json, installed better-auth@1.6.23 package internals, and current Cloudflare D1 docs on 2026-06-30. Updated to exclude @better-auth/infra from implementation scope.

Summary

Recommendation Use Better Auth's built-in Kysely adapter path by passing the Cloudflare D1Database binding directly as database: env.DB. Do not add Drizzle.
Project fit The Worker already has a D1 binding named DB and nodejs_compat enabled.
Primary risk The current auth module is static and imports better-sqlite3, which is not the desired Cloudflare runtime database path.
Fix shape Create a request/env-scoped auth factory, add SQL migrations for Better Auth tables, expose /api/auth/*, then guard application routes or server functions with auth.api.getSession.

Current State

Findings

CONFIRMED

Better Auth can detect D1 without Drizzle

Installed better-auth@1.6.23 includes a Kysely adapter path that recognizes an object with batch, exec, and prepare as SQLite/D1, then loads an internal D1 SQLite dialect.

Evidence: @better-auth/kysely-adapter package code checks for "batch" in db && "exec" in db && "prepare" in db and constructs D1SqliteDialect.
IMPORTANT

D1 platform constraints matter for auth design

Cloudflare D1 is SQLite-compatible and serverless, but each individual D1 database processes queries one at a time. Current D1 limits include a maximum 100 bound parameters per query, 30 second maximum SQL query duration, and six simultaneous D1 connections per Worker invocation.

Evidence: Cloudflare D1 docs and D1 limits page retrieved on 2026-06-30.
Design implication

Auth queries should stay small and indexed. Session, account, verification, and user lookup columns need indexes matching the Better Auth access patterns.

OUT OF SCOPE

Better Auth Infrastructure is not part of this plan

Do not configure @better-auth/infra, Sentinel, the Better Auth Infrastructure dashboard, or infrastructure-managed email/SMS as part of this D1 implementation. The initial scope is self-hosted Better Auth on the existing Cloudflare Worker and D1 database.

RECOMMENDED

Use explicit SQL migrations, not runtime schema generation

Because this project already manages D1 schema through migrations/*.sql, add a normal migration for Better Auth tables instead of relying on Drizzle or generated ORM migrations.

Drizzle Tradeoffs

Option Pros Cons
Drizzle adapter Strong TypeScript schema modeling, a documented Better Auth adapter path, generated migrations, query-builder ergonomics, and easier schema reuse if the rest of the app adopts Drizzle. Adds an ORM dependency and migration workflow this project does not currently use, creates two database access styles beside the existing direct D1 SQL code, and can make simple auth table changes feel heavier than plain SQL migrations.
Non-Drizzle D1/Kysely path No Drizzle dependency, aligns with the project's existing hand-written D1 migrations and direct SQL style, keeps the runtime database binding simple with database: env.DB, and avoids introducing an app-wide ORM decision only for auth. Less compile-time schema help for auth migrations, more responsibility to keep SQL tables synchronized with Better Auth options/plugins, and fewer project-local abstractions if auth schema customization grows.

For this project, the non-Drizzle path is the better fit because the application already uses direct D1 APIs, already has SQL migrations, and the stated requirement is to avoid Drizzle. Drizzle becomes more attractive only if the broader application wants to standardize on an ORM/query-builder workflow.

Current Better Auth docs also distinguish the migration workflow: the built-in Kysely adapter can generate SQL or apply migrations directly, while the Drizzle adapter generates Drizzle schema and expects drizzle-kit to generate/apply migrations. That is a useful Drizzle advantage only if the project wants Drizzle's schema as the source of truth.

Implementation Plan

1. Replace the static auth singleton with an env-scoped factory

Better Auth needs the D1 binding from the current Worker request environment. Implement a small factory rather than importing a module-level auth singleton that lacks access to env.

import { betterAuth } from "better-auth";

export const createAuth = (env: Env) =>
    betterAuth({
        database: env.DB,
        secret: env.BETTER_AUTH_SECRET,
        baseURL: env.BETTER_AUTH_URL,
        trustedOrigins: [env.BETTER_AUTH_URL],
        emailAndPassword: {
            enabled: true,
        },
        plugins: [],
    });

Add secrets with wrangler secret put BETTER_AUTH_SECRET and environment-specific URL configuration. For local development, use a non-production value in .dev.vars rather than committing secrets. Better Auth can read BETTER_AUTH_SECRET and BETTER_AUTH_URL automatically in conventional Node environments; in this Worker, passing the values from Cloudflare bindings keeps the request-env dependency explicit.

2. Route /api/auth/* to auth.handler

In src/worker.ts, branch auth requests before forwarding to TanStack Start. This is the least invasive Worker integration because Better Auth already exposes a standard Fetch-compatible handler.

export default {
    async fetch(request: Request, env: Env): Promise<Response> {
        const url = new URL(request.url);

        if (url.pathname.startsWith("/api/auth/")) {
            return await createAuth(env).handler(request);
        }

        return await serverEntry.fetch(request);
    },
};

If TanStack Start provides a preferred API route pattern in this project, the same handler can be mounted there instead, but the Worker-level branch is straightforward and Cloudflare-native.

3. Fix the client setup

Import createAuthClient and set the same base auth URL. Do not include sentinelClient or any @better-auth/infra client plugin.

import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient({
    baseURL: "/api/auth",
});

4. Add session access helpers

Create a server-side helper that calls createAuth(env).api.getSession({ headers }) so pages, loaders, and server functions can consistently check the current user. Apply the guard first to write paths, then to full route loaders if the app should become private.

5. Remove the unwanted SQLite dependency path

Remove better-sqlite3 imports from application code. If it is only present because of the placeholder auth module, do not add it as a project dependency. The D1 binding is the runtime database.

Migration Plan

Add a migration such as migrations/0009_add_better_auth_tables.sql. The base Better Auth schema uses user, session, account, and verification. SQLite accepts these table names, but quoting them is safer because user can be confusing in SQL tooling.

CREATE TABLE IF NOT EXISTS "user" (
    "id" TEXT PRIMARY KEY NOT NULL,
    "name" TEXT NOT NULL,
    "email" TEXT NOT NULL UNIQUE,
    "emailVerified" INTEGER NOT NULL DEFAULT 0,
    "image" TEXT,
    "createdAt" INTEGER NOT NULL,
    "updatedAt" INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS "session" (
    "id" TEXT PRIMARY KEY NOT NULL,
    "expiresAt" INTEGER NOT NULL,
    "token" TEXT NOT NULL UNIQUE,
    "createdAt" INTEGER NOT NULL,
    "updatedAt" INTEGER NOT NULL,
    "ipAddress" TEXT,
    "userAgent" TEXT,
    "userId" TEXT NOT NULL,
    FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS "session_userId_idx" ON "session" ("userId");

CREATE TABLE IF NOT EXISTS "account" (
    "id" TEXT PRIMARY KEY NOT NULL,
    "accountId" TEXT NOT NULL,
    "providerId" TEXT NOT NULL,
    "userId" TEXT NOT NULL,
    "accessToken" TEXT,
    "refreshToken" TEXT,
    "idToken" TEXT,
    "accessTokenExpiresAt" INTEGER,
    "refreshTokenExpiresAt" INTEGER,
    "scope" TEXT,
    "password" TEXT,
    "createdAt" INTEGER NOT NULL,
    "updatedAt" INTEGER NOT NULL,
    FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS "account_userId_idx" ON "account" ("userId");
CREATE INDEX IF NOT EXISTS "account_provider_account_idx"
    ON "account" ("providerId", "accountId");

CREATE TABLE IF NOT EXISTS "verification" (
    "id" TEXT PRIMARY KEY NOT NULL,
    "identifier" TEXT NOT NULL,
    "value" TEXT NOT NULL,
    "expiresAt" INTEGER NOT NULL,
    "createdAt" INTEGER NOT NULL,
    "updatedAt" INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS "verification_identifier_idx"
    ON "verification" ("identifier");

Confirm the final schema against the exact Better Auth options and plugins used. Plugins such as organizations, admin, 2FA, passkeys, or database-backed rate limiting add more tables or columns. As a pre-implementation aid, run npx auth@latest generate --config src/lib/auth.ts --output schema.sql against the final auth config and compare the generated SQL with the hand-written D1 migration before applying it.

Validation

  1. Run npm exec -- wrangler types after adding BETTER_AUTH_SECRET or other vars to Wrangler config, if they are declared there.
  2. Apply migrations locally with npm run db:migrate:local.
  3. Start local dev with npm run dev or npx wrangler dev depending on the preferred local runtime path.
  4. Verify GET /api/auth/ok returns { status: "ok" }.
  5. Exercise /api/auth/sign-up/email, /api/auth/sign-in/email, /api/auth/get-session, and /api/auth/sign-out through the Better Auth client.
  6. Run npm run build, npm run test, and npm run check before deploying.
  7. Apply remote migration with npm run db:migrate:remote before deploying auth-dependent code.

Open Questions