VelaVela
Better Auth

Cloudflare D1

Run @velajs/better-auth on Cloudflare Workers backed by a D1 database.

Cloudflare Workers + D1 is a common deployment target for edge-first auth. This page walks through the wiring; it assumes you've read the overview and configuration.

Why forRootAsync is required

Cloudflare bindings (D1, KV, R2, Hyperdrive, Durable Objects) are populated at request time, not at module load. The env.DB you reach for in your factory is undefined during VelaFactory.create() and gets filled in by @velajs/cloudflare's middleware on the first fetch.

BetterAuthModule.forRootAsync defers the user factory until the first BetterAuthService.auth access — by then runtime middleware has run and d1.database is safe to read. This timing is the whole reason the async mode exists.

End-to-end wiring

import { betterAuth } from 'better-auth';
import { drizzleAdapter } from '@better-auth/drizzle-adapter';
import { drizzle } from 'drizzle-orm/d1';
import { Controller, Get, Module } from '@velajs/vela';
import { D1Module, D1Service, createCloudflareApp } from '@velajs/cloudflare';
import {
  AuthGuard, BetterAuthModule, CurrentUser, Public,
  type User,
} from '@velajs/better-auth';

@Controller('/me')
class MeController {
  @Get() me(@CurrentUser() user: User) {
    return { id: user.id, email: user.email };
  }
}

@Controller('/healthz')
class HealthController {
  @Get() @Public(true) ok() { return { ok: true }; }
}

@Module({
  imports: [
    D1Module.forRoot({ binding: 'DB' }),
    BetterAuthModule.forRootAsync({
      inject: [D1Service],
      useFactory: (d1) =>
        betterAuth({
          secret: 'set-from-env-in-production',
          baseURL: 'https://your-worker.example',
          database: drizzleAdapter(drizzle(d1.database), { provider: 'sqlite' }),
          emailAndPassword: { enabled: true, autoSignIn: true },
        }),
      isGlobal: true,
    }),
  ],
  controllers: [MeController, HealthController],
})
class AppModule {}

export default await createCloudflareApp(AppModule);

Three details worth flagging:

  • createCloudflareApp(AppModule) (not VelaFactory.create) — installs the env-capture middleware that initializes D1Service.database on first fetch.
  • @velajs/cloudflare is the runtime adapter providing D1Module and D1Service; bring it in via pnpm add @velajs/cloudflare.
  • Direct adapter import — @better-auth/drizzle-adapter, not better-auth/adapters/drizzle. The latter is an export * re-export that esbuild wraps in an async init shim under workerd, leaving drizzleAdapter undefined at top-level evaluation.

wrangler.toml

name = "your-worker"
main = "dist/worker.js"
compatibility_date = "2024-09-23"
# better-auth uses AsyncLocalStorage internally.
compatibility_flags = ["nodejs_compat"]

[[d1_databases]]
binding = "DB"
database_name = "your_app_auth"
database_id = "<UUID from `wrangler d1 create`>"

Schema

Better-auth needs four tables (user, session, account, verification) plus indexes and foreign keys. Generate them with the better-auth CLI:

pnpm dlx better-auth generate

Drop the output into a migrations/ folder and apply with:

wrangler d1 execute your_app_auth --local --file=migrations/0000_initial.sql   # dev
wrangler d1 execute your_app_auth --remote --file=migrations/0000_initial.sql  # prod

If you add better-auth plugins (twoFactor, organization, admin, magicLink, passkey), regenerate the schema — each plugin contributes additional tables/columns.

Edge-safety bundle scan

After your first build, verify your bundled worker doesn't pull in node: modules:

grep -rn "from 'node:\|from \"node:" dist/worker.js

Should return nothing. If it does, the offender is usually a database adapter — see the adapter matrix in the overview.

On this page