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.
What happened
On a Cloudflare Worker with a D1 binding and no
DATABASE_URL, app-datamigrations (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:
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 guardshort-circuiting everything.
Root cause
dist/db/client.jsanddist/db/create-get-db.jsboth have a real D1branch:
getDialect()returns"d1"whenDATABASE_URLis unset andglobalThis.__cf_env?.DB/__env__?.DBis present, andcreate-get-db.jscallsdrizzle-orm/d1'sdrizzle(d1Binding, …)for it.App data works correctly on D1 because of this.
dist/server/better-auth-instance.jshas no such branch. InbuildDatabaseConfig(dialect):dialect(which is"d1"in this configuration) is passed in and nevertested — there's a
postgresarm and a SQLite/libSQL tail, and D1 fallsinto the SQLite tail via the empty-
DATABASE_URLdefault. That tail doesawait import("better-sqlite3"), which on a Cloudflare Worker build isdeliberately stubbed by
CLOUDFLARE_WORKER_STUB_MODULESindist/deploy/build.js:180-181:defaultis{}, sonew Database(filePath)becomesnew {}()→TypeError: e is not a constructorin the minified bundle. I confirmedthis is exactly what ships in a real
wrangler deploy-able build, not adev-only artifact: in
.output/server/_libs/drizzle-orm.mjsthere-exported
better-sqlite3shim is verbatim{Database: class {}, default: {}, watch: () => ({ close() {} })}, and the bundled auth code callsnew e(...)on thatdefault: {}.Grepping
dist/server/better-auth-instance.jsfor"d1"returns zerohits, in both
0.146.5and0.146.8(currently latest).Why this can't be worked around today
getDialect()/getDatabaseUrl()(dist/db/client.js) are sharedbetween app data and auth. Setting
DATABASE_URL(or the<APP_NAME>_DATABASE_URLper-app override) to point auth at an externalPostgres/libSQL database also flips
getDialect()for app data — theresolver 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 acceptsbasePath | socialProviders | plugins | googleScopes— nodatabaseoverride.
buildDatabaseConfigis 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 tobuildDatabaseConfig, mirroring thegating already in
dist/db/create-get-db.js:One thing worth checking while implementing: #2577 found
db.transaction()fails on D1 (no interactive transactions — D1 needs
batch()/runAtomicWritesinstead). If Better Auth's own internals or thedrizzleAdapterpath issue adb.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
Watch for
Applied migration "better-auth-core-tables"followed byFailed to initialize Better Auth: TypeError: e is not a constructorandAuth guard registered despite init failure — app is locked.Environment
@agent-native/core:0.146.5(also reproduces on0.146.8, latest;dist/server/better-auth-instance.jsis byte-identical between the two)wrangler:4.120.0workerdviawrangler dev --local(miniflare), D1 binding namedDB,DATABASE_URLunsetwrangler deploy(only localworkerdso far), but the stubbing is a build-time decision baked into the
.outputbundle thatwrangler deployships as-is, so I'd expectidentical behavior in production.