Kemal Yılmaz front-end developer

Setting up Better Auth with Cloudflare Workers + D1 + Kysely

Setting up Better Auth in a Cloudflare environment comes with unique challenges. This repository and demo show how to configure an example setup.


If you’re looking for a concise version, see the repository’s README.md and the code comments.


Repository and Demo

GitHub Repository:

https://github.com/xkema/demo-better-auth-cloudflare-d1-kysely-quickfixes-sveltekit

A demo page with Better Auth (1.3.34):

https://demo-better-auth-cloudflare-d1-kysely-quickfixes-sveltekit.missofis.workers.dev

Intro

I’ve been experimenting with the Cloudflare Developer Platform for a while, especially with the serverless platform Workers and the SQLite database D1. When running within the platform, the applications I’ve built worked seamlessly.

However, when trying to set up a Better Auth instance on the platform, I encountered several challenges. I’ll explain and address them in this article.

This post compiles untidy information about Better Auth integration; related links and issues are listed at the end of the post if you’re interested.

Code sections in this post are trimmed to save space; please refer to the GitHub repository for the full content.

Lastly, I tried to keep the content aligned with the Better Auth installation flow. You may rewrite the code in your preferred style and syntax.

Before Starting

The fixes presented in this post should be considered quick fixes/patches, not solutions. This level of patching significantly reduces production quality, and I discourage relying on them heavily because:

Problems & Quickfixes

(1) Accessing the D1 database instance outside the Worker runtime

Problem: The Cloudflare Workers runtime (workerd) is an isolated environment, and you can’t access the production runtime from outside (e.g., from a framework’s Node.js server or a custom Node.js script). The runtime is bound to the request/response cycle of the Worker, including access to the D1 database instance defined as a binding in Cloudflare’s configuration.

Quickfix: For every request made to the Cloudflare Worker, re-instantiate the Better Auth config by passing the actual D1 instance from the request. To do this, instead of exporting a single betterAuth({...}) instance from the Better Auth config, export a helper function that accepts the actual D1 instance as a parameter and passes it to the Kysely constructor.


When following the Better Auth installation flow, the first obstacle is determining what to pass as the database property for the Kysely D1 dialect. Possible candidates are listed below, but none of them solve the problem:

None of these provide access to the actual D1 instance. So how do we handle this?

Below is the quickfix code that changes the auth.ts configuration to export a function instead of an instance. I use an auth.with(...) function here, but you can use an auth() function directly, a class-based approach, or something similar.

// return an `auth.with(...)` function to pass the database instance at the runtime
export const auth = {
  with: (d1Database: D1Database) => {
    return betterAuth({
      database: {
        type: "sqlite",
        db: new Kysely({
          dialect: new D1Dialect({
            database: d1Database,
          }),
        }),
      },
      // ...
      // ...
    });
  },
};

When accessing the Better Auth APIs, use auth.with(...).api.* in place of auth.api.*.

// in the code we'll call better auth api methods like this:
await auth.with(platform.env.DB).api.getSession({});
// `platform` is a property of SvelteKit's `RequestEvent`, in other frameworks,
// extract it from the framework's equivalent event or helper

As an alternative solution, you may attach the betterAuth({...}) instance to the event.locals.* object and access it as event.locals.auth.api.*.

(2) Table access error SQLITE_AUTH on D1 when running some Better Auth CLI commands or DB interactions

Problem: Cloudflare D1 databases include an auto-generated internal table called _cf_METADATA. This table is access-prohibited, but Kysely’s database introspection attempts to read it. (Better Auth’s generate command uses this feature as well.)

Quickfix: [Only for the CLI generate command.] Intercept the D1 methods used by the generate command, remove _cf_METADATA from the incoming database query, and run the modified query instead of the original.


This error is raised by the Kysely query builder itself, not by Better Auth or the Kysely D1 adapter. Although not strictly part of the main issues discussed here, it’s still a blocker.

// this block adds an extra clause to every query to filter out a specific value
//
// not the best solution, but it's only used in the `generate` command, which
// runs two relevant queries
//
// later we'll bind `_cf_METADATA` to the query, and it will be built as `and
// "name" != _cf_METADATA`
const removeCFMetadataFromQuery = (query) => {
  const sections = query.split(/(?=where|order\sby)/);
  const where_section_index = sections.findIndex((section) => section.startsWith("where"));
  sections[where_section_index] += `and "name" != ? `;
  const query_cf_removed = sections.join("");
  return query_cf_removed;
};

(3) Unable to run the built-in migration npx @better-auth/cli@latest generate script

Problem: Better Auth CLI’s generate command needs to run actual database queries against the local database to determine what has changed when adding/removing plugins or initializing the database. This decides whether to use CREATE or ALTER statements depending on the current database state. The same D1 access issue from the first problem applies here, as the generate command runs in a pure Node.js process, not inside the workerd runtime.

Quickfix: Create an alternative generate CLI command using Cloudflare’s workerd emulator getPlatformProxy(), designed specifically for this purpose. This was intended to be part of the Better Auth CLI but, for some reason—likely related to the next issue—it is not included.


Better Auth CLI’s generate command (npx @better-auth/cli generate) will not work with the custom auth export we wrote above. Instead, we need a custom Node.js script to replace the default generate command:

  1. Read the emulated env object to access the D1 instance inside the Node.js script.
  2. Intercept database methods used by the migration script to avoid the SQLITE_AUTH error caused by attempts to access the prohibited _cf_METADATA table.
  3. Create a Better Auth instance with the emulated D1 instance and shared options from the original auth.ts config.
  4. Run the original getMigrations() helper from the better-auth package to generate migration content.
  5. Handle the generated SQL file as needed.

A limited portion of the script is shared here; refer to the repository for the full code.

// get access to the local workerd bindings
const { env } = await getPlatformProxy(/* ... */);

// intercept and skip the database execution then run the same functions with
// the modified query
const d1DatabaseMock = {
  prepare: (query) => ({
    bind: (...values) => ({
      all: async () => {
        // ...
        // query is modified here to fix `_cf_METADATA` access problem (`removeCFMetadataFromQuery` method above)
        // ...
        const response = await env.DBM.prepare(/* modified query */).bind(/* values + _cf_METADATA */).all();
        return response;
      },
    }),
  }),
};

// create a better auth instance with the mock database
const auth = betterAuth({
  database: {
    type: "sqlite",
    dialect: new D1Dialect({
      database: d1DatabaseMock,
    }),
  },
  // ...
  // common configuration options
  // ...
});

// at this point we're ready to use the D1 instance without any other problems
export const generateMigrations = async ({ options, file }) => {
  // ...
  // generate migrations by using the `better-auth` internals
  // ...
};

// ...
// and finally write the output into the default path of `better-auth` migrations
// ...

(4) Module import errors

Problem: Accessing exports from the cloudflare:workers and wrangler packages is not possible in the Better Auth module system. The former is expected, as cloudflare:workers is meant to run only within the workerd runtime (accessing env at the top level is just a convenience). The wrangler package is designed for Node.js, but Better Auth’s module loading likely prevents it from working as intended.

Quickfix: Module access issues are partially addressed by the previous quickfixes. However, there is still no clear solution for using getPlatformProxy() in the Better Auth config for development environments.

Other Possible Solutions

  1. Separate the authentication database.

    Requires manually maintaining connections between the authentication database tables and the application database.

  2. Use the default driver only for migrations.

    Using the better-sqlite3 package is generally safe, since the generate script only runs introspection queries. However, you still need to monitor the internal process of the generate script.

  1. Kysely issue on the D1 error SQLITE_AUTH - https://github.com/kysely-org/kysely/issues/1571
  2. Import issue on cloudflare:workers module - https://github.com/better-auth/better-auth/pull/5559

Outro

Authentication is expensive, both in terms of budget and development effort. Better Auth offers appealing features out of the box, but integrating it with Cloudflare’s serverless solutions is not straightforward. This will likely improve in the future, but for now, these challenges must be considered. Decide carefully whether you want to maintain a heavily quickfixed codebase.