Setting up Better Auth with Cloudflare Workers + D1 + Kysely
- Better Auth
- 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
- Intro
- Before Starting
- Problems & Quickfixes
- Other Possible Solutions
- Links
- Outro
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:
- They’re not maintainable and fall into the save the day, ruin the future category.
- They make future package updates extremely painful.
- They fix only a limited portion of the problem, individual CLI commands will also need custom fixes.
- This fixes might break the client plugin created with
export const authClient = createAuthClient({...});. - …and likely more.
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:
process.env— Cloudflare docs state that this is populated with string-based values, not actual bindings.import { env } from "cloudflare:workers"— This module is only a helper within the Cloudflare runtime context, allowing you to import theenvobject at the top level for convenience.const { env } = await getPlatformProxy()— Intended as a local development helper, but only for local environments. Better Auth’s module resolution system blocks this as well (import { getPlatformProxy } from "wrangler"). Importing it works, but calling it does not, likely awaiting a fix in Better Auth.const { env } = await getCloudflareContext()— OpenNext’s replacement forgetPlatformProxy(). Same issue as above.const { platform } = getRequestEvent()— SvelteKit’s helper for accessing the request event. This is only valid inside the request/response lifecycle.
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’sgeneratecommand uses this feature as well.)Quickfix: [Only for the CLI
generatecommand.] Intercept the D1 methods used by thegeneratecommand, remove_cf_METADATAfrom 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
generatecommand 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 useCREATEorALTERstatements depending on the current database state. The same D1 access issue from the first problem applies here, as thegeneratecommand runs in a pure Node.js process, not inside theworkerdruntime.Quickfix: Create an alternative
generateCLI command using Cloudflare’sworkerdemulatorgetPlatformProxy(), 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:
- Read the emulated
envobject to access the D1 instance inside the Node.js script. - Intercept database methods used by the migration script to avoid the
SQLITE_AUTHerror caused by attempts to access the prohibited_cf_METADATAtable. - Create a Better Auth instance with the emulated D1 instance and shared options from the original
auth.tsconfig. - Run the original
getMigrations()helper from thebetter-authpackage to generate migration content. - 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:workersandwranglerpackages is not possible in the Better Auth module system. The former is expected, ascloudflare:workersis meant to run only within theworkerdruntime (accessingenvat the top level is just a convenience). Thewranglerpackage 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
-
Separate the authentication database.
Requires manually maintaining connections between the authentication database tables and the application database.
-
Use the default driver only for migrations.
Using the
better-sqlite3package is generally safe, since thegeneratescript only runs introspection queries. However, you still need to monitor the internal process of thegeneratescript.
Links
- Kysely issue on the D1 error
SQLITE_AUTH- https://github.com/kysely-org/kysely/issues/1571 - Import issue on
cloudflare:workersmodule - 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.