Skip to content

Better Auth cannot initialize on Cloudflare Workers + D1 — buildDatabaseConfig has no d1 branch #2766

Description

@MetaBureau

What happened

On a Cloudflare Worker with a D1 binding and no DATABASE_URL, app-data
migrations (including the Better Auth core tables) apply cleanly onto D1,
but Better Auth itself fails to initialize and the app permanently 404s
behind the auth guard:

[db] Applied migration "better-auth-core-tables" v1 (8 statements)
… (rest of domain schema lands on D1 without error)

✘ [ERROR] [agent-native] Failed to initialize Better Auth: TypeError: e is not a constructor
    at yIe (_libs/@agent-native/core.mjs:699:30589)
[agent-native] Auth guard registered despite init failure — app is locked.

Every route then 404s (Cannot find any route matching), including
/_agent-native/health (503) — the SSR catch-all route ({route: "/**:page", method: "get"}) is registered fine; it's the auth guard
short-circuiting everything.

Root cause

dist/db/client.js and dist/db/create-get-db.js both have a real D1
branch: getDialect() returns "d1" when DATABASE_URL is unset and
globalThis.__cf_env?.DB / __env__?.DB is present, and
create-get-db.js calls drizzle-orm/d1's drizzle(d1Binding, …) for it.
App data works correctly on D1 because of this.

dist/server/better-auth-instance.js has no such branch. In
buildDatabaseConfig(dialect):

function buildDatabaseConfig(dialect) {
    if (dialect === "postgres") {  }
    // SQLite / libsql
    const url = getDatabaseUrl("file:./data/app.db");
    if (url.startsWith("file:") || !url.includes("://")) {
        const { default: Database } = await import("better-sqlite3");
        const filePath = url.replace(/^file:/, "");
        const sqlite = new Database(filePath);   // <-- throws on Workers
        
    }
    
}

dialect (which is "d1" in this configuration) is passed in and never
tested — there's a postgres arm and a SQLite/libSQL tail, and D1 falls
into the SQLite tail via the empty-DATABASE_URL default. That tail does
await import("better-sqlite3"), which on a Cloudflare Worker build is
deliberately stubbed by CLOUDFLARE_WORKER_STUB_MODULES in
dist/deploy/build.js:180-181:

"better-sqlite3": "export default {}; export const Database = class {}; export const watch = () => ({ close() {} });\n",

default is {}, so new Database(filePath) becomes new {}()
TypeError: e is not a constructor in the minified bundle. I confirmed
this is exactly what ships in a real wrangler deploy-able build, not a
dev-only artifact: in .output/server/_libs/drizzle-orm.mjs the
re-exported better-sqlite3 shim is verbatim {Database: class {}, default: {}, watch: () => ({ close() {} })}, and the bundled auth code calls new e(...) on that default: {}.

Grepping dist/server/better-auth-instance.js for "d1" returns zero
hits, in both 0.146.5 and 0.146.8 (currently latest).

Why this can't be worked around today

  • getDialect() / getDatabaseUrl() (dist/db/client.js) are shared
    between app data and auth. Setting DATABASE_URL (or the
    <APP_NAME>_DATABASE_URL per-app override) to point auth at an external
    Postgres/libSQL database also flips getDialect() for app data — the
    resolver is memoized process-wide and has no per-caller path, so app data
    moves off D1 too. There's no way to keep app data on the D1 binding while
    auth uses a different backend.
  • BetterAuthConfig (dist/server/better-auth-instance.d.ts) only accepts
    basePath | socialProviders | plugins | googleScopes — no database
    override.
  • buildDatabaseConfig is not exported.

Net effect: there is currently no configuration in which a Cloudflare
Worker app using the D1 binding can also authenticate a user with Better
Auth.

Suggested fix

Add a dialect === "d1" branch to buildDatabaseConfig, mirroring the
gating already in dist/db/create-get-db.js:

if (dialect === "d1") {
    const d1 = getCloudflareD1Binding();
    const { drizzle } = await import("drizzle-orm/d1");
    const db = drizzle(d1, { schema: sqliteAuthSchema });
    const { drizzleAdapter } = await import("better-auth/adapters/drizzle");
    return drizzleAdapter(db, { provider: "sqlite", schema: sqliteAuthSchema });
}

One thing worth checking while implementing: #2577 found db.transaction()
fails on D1 (no interactive transactions — D1 needs batch()/
runAtomicWrites instead). If Better Auth's own internals or the
drizzleAdapter path issue a db.transaction() anywhere in the session/
account flows, that would need the same treatment, or D1 auth will pass
initialization and then fail on first sign-in.

Reproduction

# any template app, wrangler.jsonc with a D1 binding named DB, DATABASE_URL unset
pnpm build:cloudflare
npx wrangler dev --config .output/server/wrangler.json --local --persist-to /tmp/d1state
# wait ~20s for async migrations, then:
curl -i localhost:8787/

Watch for Applied migration "better-auth-core-tables" followed by
Failed to initialize Better Auth: TypeError: e is not a constructor and
Auth guard registered despite init failure — app is locked.

Environment

  • @agent-native/core: 0.146.5 (also reproduces on 0.146.8, latest;
    dist/server/better-auth-instance.js is byte-identical between the two)
  • wrangler: 4.120.0
  • Real workerd via wrangler dev --local (miniflare), D1 binding named
    DB, DATABASE_URL unset
  • Not yet confirmed against a live wrangler deploy (only local workerd
    so far), but the stubbing is a build-time decision baked into the
    .output bundle that wrangler deploy ships as-is, so I'd expect
    identical behavior in production.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions