[GH-ISSUE #1143] Not compatible with Cloudflare Workers or edge environments #8615

Closed
opened 2026-04-13 03:44:31 -05:00 by GiteaMirror · 27 comments
Owner

Originally created by @enqqi on GitHub (Jan 5, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/1143

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. bun create hono@latest my-app (select workers template)
  2. Setup better-auth for hono follow official better-auth docs
  3. While setting it up, the documentation specifies that Better-Auth expects a specific export in a specific directory.
  4. Here is where the first problem starts... You cannot follow the prescribed setup in a serverless environment (e.g., Cloudflare Workers) because env or bindings are not globally accessible.
  5. Attempt to work around this limitation:
    • Initialize the auth client dynamically in a middleware and set it in the context.
    • However, when using this approach, the sign-in and sign-up endpoints return a 500 error. This happens due to the use of this internally by Better-Auth (I think).
    • one of the errors was illegal use of this.
    • The error occurs because the workaround relies on dynamically creating the auth client as a function, which is incompatible with Better-Auth’s design. Here’s an example of the workaround that triggers the issue:
export const getAuth = (env: Env) => betterAuth({ ... });
  1. Better-Auth requires the auth instance to be a constant and located in a specific directory as per its documentation. This restriction makes it incompatible with the dynamic initialization needed for serverless environments like Cloudflare Workers.

Current vs. Expected behavior

Current Behavior

When attempting to use Better-Auth in edge environments like Cloudflare Workers, unexpected issues occur that prevent seamless integration and functionality.

  1. Immutable Headers Error (500):

    • After creating a user or logging in, a 500 error is thrown related to immutable headers.
  2. Incompatibility with Exporting auth as a Function:

    • Better-Auth’s internal packages fail to handle auth being exported as a function. This is necessary to dynamically pass secrets and bindings like KV.
    • Workaround: Resorted to export const auth = ... and hardcoded sensitive variables (e.g., DB_URL), which is not ideal for security and flexibility.
  3. Custom SecondaryStorage KV Issue:

    • Unable to use a custom SecondaryStorage KV because it requires passing the actual KV binding, which is not supported in the current setup.

Expected Behavior

  1. Seamless integration of Better-Auth with edge environments like Cloudflare Workers.
  2. Ability to:
    • Export auth as a function to dynamically handle secrets and bindings.
    • Pass custom KV bindings for SecondaryStorage without issues.
  3. No immutable header-related errors after user creation or login.

What version of Better Auth are you using?

1.1.10

Provide environment information

OS: macOS 15.2 24C101 arm64 
Host: Mac16,7 
Kernel: 24.2.0 
CPU: Apple M4 Pro 
GPU: Apple M4 Pro 
Memory: 4946MiB / 49152MiB

Which area(s) are affected? (Select all that apply)

Backend, Package

Auth config (if applicable)

Yes, I have nodejs_compat and als flags


export const auth = betterAuth({
  database: drizzleAdapter(drizzle(env.DB_URL), {
    provider: 'pg'
  }),
  baseURL: env.BASE_URL,
  secret: env.BASE_URL,
  plugins: [openAPI()],
  emailAndPassword: {
    enabled: true,
  },
  secondaryStorage: {
    get: env.AuthKV.get,
    set: (k, v) => env.AuthKV.put(k, v),
    delete: env.AuthKV.delete,
  }
})

Additional context

Global Variables or Storage:

  • While AsyncLocalStorage (similar to what Next.js uses) could be a potential solution, it requires a function context. Since this is not a function-based approach, store/context remains undefined.

Possible Solutions

  • Allow Better-Auth to be initialized as a function that accepts secrets and bindings as parameters, enabling dynamic initialization.
  • Provide a mechanism to use global variables in edge environments.
  • Explore leveraging AsyncLocalStorage or a similar approach to maintain context across asynchronous calls.

For further ideas, this discussion offers useful insights.

Originally created by @enqqi on GitHub (Jan 5, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/1143 ### Is this suited for github? - [X] Yes, this is suited for github ### To Reproduce 1. `bun create hono@latest my-app` (select workers template) 2. Setup better-auth for hono follow official better-auth [docs](https://www.better-auth.com/docs/integrations/hono) 3. While setting it up, the documentation specifies that Better-Auth expects a specific export in a [specific directory](https://www.better-auth.com/docs/installation#create-a-better-auth-instance). 4. Here is where the first problem starts... You cannot follow the prescribed setup in a serverless environment (e.g., Cloudflare Workers) because env or bindings are not globally accessible. 5. Attempt to work around this limitation: - Initialize the auth client dynamically in a middleware and set it in the context. - However, when using this approach, the sign-in and sign-up endpoints return a 500 error. This happens due to the use of this internally by Better-Auth (I think). - one of the errors was illegal use of `this`. - The error occurs because the workaround relies on dynamically creating the auth client as a function, which is incompatible with Better-Auth’s design. Here’s an example of the workaround that triggers the issue: ```typescript export const getAuth = (env: Env) => betterAuth({ ... }); ``` 7. Better-Auth requires the auth instance to be a constant and located in a specific directory as per its documentation. This restriction makes it incompatible with the dynamic initialization needed for serverless environments like Cloudflare Workers. ### Current vs. Expected behavior ## Current Behavior When attempting to use Better-Auth in edge environments like Cloudflare Workers, unexpected issues occur that prevent seamless integration and functionality. 1. **Immutable Headers Error (500):** - After creating a user or logging in, a 500 error is thrown related to immutable headers. 2. **Incompatibility with Exporting `auth` as a Function:** - Better-Auth’s internal packages fail to handle `auth` being exported as a function. This is necessary to dynamically pass secrets and bindings like KV. - **Workaround:** Resorted to `export const auth = ...` and hardcoded sensitive variables (e.g., `DB_URL`), which is not ideal for security and flexibility. 3. **Custom `SecondaryStorage` KV Issue:** - Unable to use a custom `SecondaryStorage` KV because it requires passing the actual KV binding, which is not supported in the current setup. ## Expected Behavior 1. Seamless integration of Better-Auth with edge environments like Cloudflare Workers. 2. Ability to: - Export `auth` as a function to dynamically handle secrets and bindings. - Pass custom KV bindings for `SecondaryStorage` without issues. 3. No immutable header-related errors after user creation or login. ### What version of Better Auth are you using? 1.1.10 ### Provide environment information ```bash OS: macOS 15.2 24C101 arm64 Host: Mac16,7 Kernel: 24.2.0 CPU: Apple M4 Pro GPU: Apple M4 Pro Memory: 4946MiB / 49152MiB ``` ### Which area(s) are affected? (Select all that apply) Backend, Package ### Auth config (if applicable) ```typescript Yes, I have nodejs_compat and als flags export const auth = betterAuth({ database: drizzleAdapter(drizzle(env.DB_URL), { provider: 'pg' }), baseURL: env.BASE_URL, secret: env.BASE_URL, plugins: [openAPI()], emailAndPassword: { enabled: true, }, secondaryStorage: { get: env.AuthKV.get, set: (k, v) => env.AuthKV.put(k, v), delete: env.AuthKV.delete, } }) ``` ### Additional context **Global Variables or Storage:** - While `AsyncLocalStorage` (similar to what Next.js uses) could be a potential solution, it requires a function context. Since this is not a function-based approach, store/context remains undefined. ### Possible Solutions - Allow Better-Auth to be initialized as a function that accepts secrets and bindings as parameters, enabling dynamic initialization. - Provide a mechanism to use global variables in edge environments. - Explore leveraging `AsyncLocalStorage` or a similar approach to maintain context across asynchronous calls. For further ideas, [this discussion](https://x.com/wesbos/status/1749974802050261339) offers useful insights.
GiteaMirror added the lockedbug labels 2026-04-13 03:44:31 -05:00
Author
Owner

@kziemski commented on GitHub (Jan 6, 2025):

Less of a bug more of a feature enhancement and documentation issue.
I have react-router , better-auth, cloudlfare d1 and secondary storage with KV working in one.
Going to redo it as its own endpoint using hono.
but actually the frameworks get in the way.

<!-- gh-comment-id:2573593976 --> @kziemski commented on GitHub (Jan 6, 2025): Less of a bug more of a feature enhancement and documentation issue. I have react-router , better-auth, cloudlfare d1 and secondary storage with KV working in one. Going to redo it as its own endpoint using hono. but actually the frameworks get in the way.
Author
Owner

@enqqi commented on GitHub (Jan 6, 2025):

Could you please share your better-auth export (hide any sensitive info) just curious as to how did you pass secrets to the better-auth config @kziemski I think react-router gets deployed to Cloudflare pages not workers?

<!-- gh-comment-id:2573920459 --> @enqqi commented on GitHub (Jan 6, 2025): Could you please share your better-auth export (hide any sensitive info) just curious as to how did you pass secrets to the better-auth config @kziemski I think react-router gets deployed to Cloudflare pages not workers?
Author
Owner

@kumardeo commented on GitHub (Jan 7, 2025):

one of the errors was illegal use of this.

I am not sure, I think this is because of the following where you forgot to bind actual contexts for env.AuthKV.get and env.AuthKV.delete methods:

  secondaryStorage: {
    get: env.AuthKV.get,
    set: (k, v) => env.AuthKV.put(k, v),
    delete: env.AuthKV.delete,
  }

This should be something like:

  secondaryStorage: {
    get: env.AuthKV.get.bind(env.AuthKV),
    set: (k, v) => env.AuthKV.put(k, v),
    delete: env.AuthKV.delete.bind(env.AuthKV),
  }

Or:

  secondaryStorage: {
    get: (k) => env.AuthKV.get(k),
    set: (k, v) => env.AuthKV.put(k, v),
    delete: (k) => env.AuthKV.delete(k),
  }
<!-- gh-comment-id:2576262708 --> @kumardeo commented on GitHub (Jan 7, 2025): > one of the errors was illegal use of `this`. I am not sure, I think this is because of the following where you forgot to bind actual contexts for `env.AuthKV.get` and `env.AuthKV.delete` methods: ``` secondaryStorage: { get: env.AuthKV.get, set: (k, v) => env.AuthKV.put(k, v), delete: env.AuthKV.delete, } ``` This should be something like: ``` secondaryStorage: { get: env.AuthKV.get.bind(env.AuthKV), set: (k, v) => env.AuthKV.put(k, v), delete: env.AuthKV.delete.bind(env.AuthKV), } ``` Or: ``` secondaryStorage: { get: (k) => env.AuthKV.get(k), set: (k, v) => env.AuthKV.put(k, v), delete: (k) => env.AuthKV.delete(k), } ```
Author
Owner

@enqqi commented on GitHub (Jan 7, 2025):

Thank you for the help! 😄 Just to clarify, env isn’t actually available in this context—it was only included for illustrative purposes. Unfortunately, I can’t access env at the top level in CF workers as required by better-auth.

<!-- gh-comment-id:2576271392 --> @enqqi commented on GitHub (Jan 7, 2025): Thank you for the help! 😄 Just to clarify, `env` isn’t actually available in this context—it was only included for illustrative purposes. Unfortunately, I can’t access env at the top level in CF workers as required by better-auth.
Author
Owner

@darrenbutcher commented on GitHub (Jan 8, 2025):

Was having the same issue outline in 1. Immutable Headers with Hono & Expo (specifically onRequest). After doing alot of digging, as per the Hono Proxy docs, needed to clone the request object. The original expo (server) plugin sets the headers directly which causes the error. Bases on a solution I found elsewhere I decided to create my own plugin based on the original (the key bit):

    async onRequest(request, ctx) {
      // If the request has an "origin" header, do nothing
      if (request.headers.get("origin")) {
        return
      }

      // Get the "expo-origin" header
      const expoOrigin = request.headers.get("expo-origin")
      // If there's no "expo-origin" header, do nothing
      if (!expoOrigin) {
        return
      }

      // Clone the original request to ensure the body is not used multiple times
      const clonedRequest = request.clone()

      // Clone the headers and set the "origin" header to the value of "expo-origin"
      const newHeaders = new Headers(clonedRequest.headers)
      newHeaders.set("origin", expoOrigin)

      // Create a new Request object with updated headers and the cloned body
      // https://hono.dev/examples/proxy Hono TIP on Immutable headers
      const updatedRequest = new Request(clonedRequest, {
        headers: newHeaders,
      })

      return {
        request: updatedRequest,
      }
    }

Hope this helps @enzojs and anyone else having this issue.

<!-- gh-comment-id:2576509063 --> @darrenbutcher commented on GitHub (Jan 8, 2025): Was having the same issue outline in 1. Immutable Headers with Hono & Expo (specifically onRequest). After doing alot of digging, as per the Hono Proxy docs, needed to `clone the request object`. The original expo (server) plugin sets the headers directly which causes the error. Bases on a solution I found elsewhere I decided to create my own plugin based on the original (the key bit): ```js async onRequest(request, ctx) { // If the request has an "origin" header, do nothing if (request.headers.get("origin")) { return } // Get the "expo-origin" header const expoOrigin = request.headers.get("expo-origin") // If there's no "expo-origin" header, do nothing if (!expoOrigin) { return } // Clone the original request to ensure the body is not used multiple times const clonedRequest = request.clone() // Clone the headers and set the "origin" header to the value of "expo-origin" const newHeaders = new Headers(clonedRequest.headers) newHeaders.set("origin", expoOrigin) // Create a new Request object with updated headers and the cloned body // https://hono.dev/examples/proxy Hono TIP on Immutable headers const updatedRequest = new Request(clonedRequest, { headers: newHeaders, }) return { request: updatedRequest, } } ``` Hope this helps @enzojs and anyone else having this issue.
Author
Owner

@kziemski commented on GitHub (Jan 8, 2025):

Could you please share your better-auth export (hide any sensitive info) just curious as to how did you pass secrets to the better-auth config @kziemski I think react-router gets deployed to Cloudflare pages not workers?

apologies @enzojs came back up for air after i was working on some timing issues unrelated.

first off auth.local.ts for the migrate.

import { createSQLiteDB } from '@miniflare/shared';
import { D1Database, D1DatabaseAPI } from '@miniflare/d1';
import type { D1Database as D1DatabaseType } from '@cloudflare/workers-types'
import { Database } from 'better-sqlite3';
import { db as genDBConnection } from './app/lib/db';
import { auth as authServer } from './app/lib/auth.server';
const local_db = await createSQLiteDB(
    '<path to the local wrangler db.sqlite>'
)
const db = new D1Database(new D1DatabaseAPI(local_db)) as unknown as D1DatabaseType;
const local_db_connection = genDBConnection(db);
export const auth = authServer(local_db_connection,null, {
    baseURL: 'http://localhost:8787',
    secret: 'secret',
})

then workers/app.ts

import '../worker-configuration.d.ts'
import { createRequestHandler } from "react-router";
import { type AuthType, loadContext } from "./load-context";

declare global {
  interface CloudflareEnvironment extends Env {}
}

const defaultHandler = {
  async fetch(request: RequestType, env: Env, ctx: ExecutionContext) {
    const args = {request, env, ctx};
    const _loadContext = loadContext(args);
    if(request.url.includes("/api/auth")) {
        return _loadContext.auth.handler(_request)
    } else {
        return requestHandler(_request,_loadContext )
    }
  } 
} 

const requestHandler = createRequestHandler(
  // @ts-expect-error - virtual module provided by React Router at build time
  () => import("virtual:react-router/server-build"),
  import.meta.env.MODE
);

export default defaultHandler;

then workers/load-context.ts

import { AppLoadContext } from "react-router";
import { type AuthType as _AuthType, auth } from "../app/lib/auth.server"
import { db as dbGen } from "../app/lib/db"
import  { type Kysely, type Dialect } from "kysely";
export type AuthType = _AuthType;
import { type ExecutionContext } from "@cloudflare/workers-types";

// type AuthType = export (db: DB, kv: KVNamespace | null, _settings: Partial<BetterAuthOptions>): ReturnType<typeof betterAuth>

declare module "react-router" {
    export interface AppLoadContext {
      cloudflare: {
        env: CloudflareEnvironment;
        ctx: ExecutionContext;
      };
      auth: AuthType;
    }
  }
let db: Kysely<Dialect> | null = null;
let authInstance: AuthType | null = null;
export type MyExecutionContext = ExecutionContext;

export function loadContext<R,E extends Env,C extends ExecutionContext >({request, env,ctx}: {request: R, env: E, ctx: C}): AppLoadContext {
  const _d1db = env.AUTH
  if(authInstance === null) {
    if(db === null)  {
      db = dbGen(_d1db)
    }
    authInstance = auth(db, env.AUTH_STORE,{ secret: env.BETTER_AUTH_SECRET || process.env.BETTER_AUTH_SECRET, } )
  }
  
  return {
    cloudflare: { env, ctx },
    auth: authInstance,
  }
}

then the vite.config.ts

import { reactRouter } from "@react-router/dev/vite";
import { cloudflareDevProxy } from '@react-router/dev/vite/cloudflare'
import autoprefixer from "autoprefixer";
import tailwindcss from "tailwindcss";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { type MyExecutionContext, loadContext } from "./workers/load-context.ts";
import { sentryVitePlugin } from "@sentry/vite-plugin";

export default defineConfig(({ isSsrBuild, ...configOptions }) => {
  
  return {
    define: {
      ...(isSsrBuild? {
        'process.env.BETTER_AUTH_SECRET': JSON.stringify(process?.env?.BETTER_AUTH_SECRET || '')
      } : {}),
    },
    server: {
      port: 8787,
    },
    build: {
      rollupOptions: isSsrBuild
        ? {
          input: {
            "assets/server-build.js": "virtual:react-router/server-build",
            "worker-entry.js": "./workers/app.ts",

          },
          treeshake: 'safest',
          output: {
            format: 'esm',
          }
        }
        : undefined,
    },
    css: {
      postcss: {
        plugins: [tailwindcss, autoprefixer],
      },
    },
    ssr: {

      target: "webworker",
      noExternal: true,
      external: ['node:async_hooks'],
      resolve: {
        conditions: ["workerd", "browser"],
        // conditions: ["workerd"],
      },
      optimizeDeps: {
        include: [
          "react",
          "react/jsx-runtime",
          "react/jsx-dev-runtime",
          "react-dom",
          "react-dom/server",
          "react-router",
        ],
      },
    },
    plugins: [
      cloudflareDevProxy({
        getLoadContext({ request,context}) {
          const { env, ctx } = context.cloudflare;
          const { props = {}, waitUntil = () => {}, passThroughOnException = () => {}, ...rest} = ctx as any;
          const _ctx: MyExecutionContext = {
            props: {},
            waitUntil: () => {},
            passThroughOnException: () => {},
            ...rest,
          }
          return loadContext({ request, env, ctx: _ctx })
        },
      }),
      reactRouter(),
      {
        // This plugin is required so both `index.js` / `worker.js can be
        // generated for the `build` config above
        name: "react-router-cloudflare-workers",
        config: () => ({
          build: {
            rollupOptions: isSsrBuild
              ? {
                  output: {
                    entryFileNames: "[name]",
                  },
                }
              : undefined,
          },
        }),
      },
      tsconfigPaths(),
    ],
  }
});

finally the wrangler.toml

name = "worker name"
compatibility_date = "2024-11-18"
compatibility_flags = ["nodejs_compat"]
main = "./build/server/worker-entry.js"
assets = { directory = "./build/client/" }
... rest of the config for d1,secrets etc
<!-- gh-comment-id:2577523592 --> @kziemski commented on GitHub (Jan 8, 2025): > Could you please share your better-auth export (hide any sensitive info) just curious as to how did you pass secrets to the better-auth config @kziemski I think react-router gets deployed to Cloudflare pages not workers? apologies @enzojs came back up for air after i was working on some timing issues unrelated. first off auth.local.ts for the migrate. ``` import { createSQLiteDB } from '@miniflare/shared'; import { D1Database, D1DatabaseAPI } from '@miniflare/d1'; import type { D1Database as D1DatabaseType } from '@cloudflare/workers-types' import { Database } from 'better-sqlite3'; import { db as genDBConnection } from './app/lib/db'; import { auth as authServer } from './app/lib/auth.server'; const local_db = await createSQLiteDB( '<path to the local wrangler db.sqlite>' ) const db = new D1Database(new D1DatabaseAPI(local_db)) as unknown as D1DatabaseType; const local_db_connection = genDBConnection(db); export const auth = authServer(local_db_connection,null, { baseURL: 'http://localhost:8787', secret: 'secret', }) ``` then workers/app.ts ``` import '../worker-configuration.d.ts' import { createRequestHandler } from "react-router"; import { type AuthType, loadContext } from "./load-context"; declare global { interface CloudflareEnvironment extends Env {} } const defaultHandler = { async fetch(request: RequestType, env: Env, ctx: ExecutionContext) { const args = {request, env, ctx}; const _loadContext = loadContext(args); if(request.url.includes("/api/auth")) { return _loadContext.auth.handler(_request) } else { return requestHandler(_request,_loadContext ) } } } const requestHandler = createRequestHandler( // @ts-expect-error - virtual module provided by React Router at build time () => import("virtual:react-router/server-build"), import.meta.env.MODE ); export default defaultHandler; ``` then workers/load-context.ts ``` import { AppLoadContext } from "react-router"; import { type AuthType as _AuthType, auth } from "../app/lib/auth.server" import { db as dbGen } from "../app/lib/db" import { type Kysely, type Dialect } from "kysely"; export type AuthType = _AuthType; import { type ExecutionContext } from "@cloudflare/workers-types"; // type AuthType = export (db: DB, kv: KVNamespace | null, _settings: Partial<BetterAuthOptions>): ReturnType<typeof betterAuth> declare module "react-router" { export interface AppLoadContext { cloudflare: { env: CloudflareEnvironment; ctx: ExecutionContext; }; auth: AuthType; } } let db: Kysely<Dialect> | null = null; let authInstance: AuthType | null = null; export type MyExecutionContext = ExecutionContext; export function loadContext<R,E extends Env,C extends ExecutionContext >({request, env,ctx}: {request: R, env: E, ctx: C}): AppLoadContext { const _d1db = env.AUTH if(authInstance === null) { if(db === null) { db = dbGen(_d1db) } authInstance = auth(db, env.AUTH_STORE,{ secret: env.BETTER_AUTH_SECRET || process.env.BETTER_AUTH_SECRET, } ) } return { cloudflare: { env, ctx }, auth: authInstance, } } ``` then the vite.config.ts ``` import { reactRouter } from "@react-router/dev/vite"; import { cloudflareDevProxy } from '@react-router/dev/vite/cloudflare' import autoprefixer from "autoprefixer"; import tailwindcss from "tailwindcss"; import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; import { type MyExecutionContext, loadContext } from "./workers/load-context.ts"; import { sentryVitePlugin } from "@sentry/vite-plugin"; export default defineConfig(({ isSsrBuild, ...configOptions }) => { return { define: { ...(isSsrBuild? { 'process.env.BETTER_AUTH_SECRET': JSON.stringify(process?.env?.BETTER_AUTH_SECRET || '') } : {}), }, server: { port: 8787, }, build: { rollupOptions: isSsrBuild ? { input: { "assets/server-build.js": "virtual:react-router/server-build", "worker-entry.js": "./workers/app.ts", }, treeshake: 'safest', output: { format: 'esm', } } : undefined, }, css: { postcss: { plugins: [tailwindcss, autoprefixer], }, }, ssr: { target: "webworker", noExternal: true, external: ['node:async_hooks'], resolve: { conditions: ["workerd", "browser"], // conditions: ["workerd"], }, optimizeDeps: { include: [ "react", "react/jsx-runtime", "react/jsx-dev-runtime", "react-dom", "react-dom/server", "react-router", ], }, }, plugins: [ cloudflareDevProxy({ getLoadContext({ request,context}) { const { env, ctx } = context.cloudflare; const { props = {}, waitUntil = () => {}, passThroughOnException = () => {}, ...rest} = ctx as any; const _ctx: MyExecutionContext = { props: {}, waitUntil: () => {}, passThroughOnException: () => {}, ...rest, } return loadContext({ request, env, ctx: _ctx }) }, }), reactRouter(), { // This plugin is required so both `index.js` / `worker.js can be // generated for the `build` config above name: "react-router-cloudflare-workers", config: () => ({ build: { rollupOptions: isSsrBuild ? { output: { entryFileNames: "[name]", }, } : undefined, }, }), }, tsconfigPaths(), ], } }); ``` finally the wrangler.toml ``` name = "worker name" compatibility_date = "2024-11-18" compatibility_flags = ["nodejs_compat"] main = "./build/server/worker-entry.js" assets = { directory = "./build/client/" } ... rest of the config for d1,secrets etc ```
Author
Owner

@kziemski commented on GitHub (Jan 8, 2025):

apologies dumped this in here and did some rough edits. this is for react-router i'll rework it for hono in a little bit but you should be able to work out any issues you might have related to cf.

<!-- gh-comment-id:2577530916 --> @kziemski commented on GitHub (Jan 8, 2025): apologies dumped this in here and did some rough edits. this is for react-router i'll rework it for hono in a little bit but you should be able to work out any issues you might have related to cf.
Author
Owner

@zhawtof commented on GitHub (Jan 21, 2025):

@kziemski I'm not exactly sure what your code is doing, but I also would love to see what you're doing in auth.server since that seems to be the part that involves the heavy lifting of converting.

Also running into the same Cloudflare Workers issue as @enzojs

<!-- gh-comment-id:2603802804 --> @zhawtof commented on GitHub (Jan 21, 2025): @kziemski I'm not exactly sure what your code is doing, but I also would love to see what you're doing in `auth.server` since that seems to be the part that involves the heavy lifting of converting. Also running into the same Cloudflare Workers issue as @enzojs
Author
Owner

@kziemski commented on GitHub (Jan 21, 2025):

//auth.server.ts
// more or less the following

import { betterAuth, type BetterAuthOptions } from "better-auth"
import { passkey } from "better-auth/plugins/passkey"
import { anonymous, magicLink, emailOTP, multiSession, jwt, admin, organization, openAPI } from "better-auth/plugins"
import { expo } from "@better-auth/expo";
import type { DB } from "./db"


export const auth = (db: DB, kv: KVNamespace | null, { trustedOrigins = [], socialProviders : defaultSocialProviders = {}, ...settings }: Partial<BetterAuthOptions>): ReturnType<typeof betterAuth> => {
    const socialProviders = {
        ...defaultSocialProviders,
        
    }
    return betterAuth({
        socialProviders,
        ...settings,
        ...(kv !== null ? {
            secondaryStorage: {
                async get(key: string) {
                    return await kv.get(key);
                },
                async set(key: string, value: string) {
                    await kv.put(key, value);
                },
                async delete(key: string) {
                    await kv.delete(key);
                },
            }
        } : {}),
        trustedOrigins: [...new Set([...trustedOrigins, 'http://localhost:8787', 'http://localhost'])],
        account: {
            accountLinking: {
                enabled: true,
            },
        },
        database: {
            db,
            type: 'sqlite',
        },
        emailAndPassword: {
            enabled: true,
            autoSignIn: true,
        },
        // rateLimit: {
        //     window: 10,
        //     max: 100,
        // },
        session: {
            expiresIn: 60 * 60 * 24 * 7,
            updateAge: 60 * 60 * 24,
            cookieCache: {
                enabled: true,
                maxAge: 5 * 60 // Cache duration in seconds
            }

        },
        plugins: [
            anonymous(),
            magicLink({
                sendMagicLink: async ({ email, url, token }) => {
                    console.log('Sending magic link to', email, 'with url', url, 'and token', token)
                }
            }),
            emailOTP({
                sendVerificationOTP: async ({ email, type }) => {
                    console.log('Sending verification OTP to', email, 'with type', type)
                }
            }),
            multiSession(),
            jwt(),
            passkey(),
            admin(),
            organization(),
            openAPI(),
            expo(),
        ]
    });
}

export type AuthType = ReturnType<typeof auth>;
export type Session = AuthType['$Infer']['Session']
<!-- gh-comment-id:2603810360 --> @kziemski commented on GitHub (Jan 21, 2025): ``` //auth.server.ts // more or less the following import { betterAuth, type BetterAuthOptions } from "better-auth" import { passkey } from "better-auth/plugins/passkey" import { anonymous, magicLink, emailOTP, multiSession, jwt, admin, organization, openAPI } from "better-auth/plugins" import { expo } from "@better-auth/expo"; import type { DB } from "./db" export const auth = (db: DB, kv: KVNamespace | null, { trustedOrigins = [], socialProviders : defaultSocialProviders = {}, ...settings }: Partial<BetterAuthOptions>): ReturnType<typeof betterAuth> => { const socialProviders = { ...defaultSocialProviders, } return betterAuth({ socialProviders, ...settings, ...(kv !== null ? { secondaryStorage: { async get(key: string) { return await kv.get(key); }, async set(key: string, value: string) { await kv.put(key, value); }, async delete(key: string) { await kv.delete(key); }, } } : {}), trustedOrigins: [...new Set([...trustedOrigins, 'http://localhost:8787', 'http://localhost'])], account: { accountLinking: { enabled: true, }, }, database: { db, type: 'sqlite', }, emailAndPassword: { enabled: true, autoSignIn: true, }, // rateLimit: { // window: 10, // max: 100, // }, session: { expiresIn: 60 * 60 * 24 * 7, updateAge: 60 * 60 * 24, cookieCache: { enabled: true, maxAge: 5 * 60 // Cache duration in seconds } }, plugins: [ anonymous(), magicLink({ sendMagicLink: async ({ email, url, token }) => { console.log('Sending magic link to', email, 'with url', url, 'and token', token) } }), emailOTP({ sendVerificationOTP: async ({ email, type }) => { console.log('Sending verification OTP to', email, 'with type', type) } }), multiSession(), jwt(), passkey(), admin(), organization(), openAPI(), expo(), ] }); } export type AuthType = ReturnType<typeof auth>; export type Session = AuthType['$Infer']['Session'] ```
Author
Owner

@enqqi commented on GitHub (Feb 4, 2025):

It would be fine if you could export an auth function that returns the better auth instance, but that is not the case as I mentioned in point 5 of my reproduction steps

<!-- gh-comment-id:2633083488 --> @enqqi commented on GitHub (Feb 4, 2025): It would be fine if you could export an auth function that returns the better auth instance, but that is not the case as I mentioned in point 5 of my reproduction steps
Author
Owner

@Kylar13 commented on GitHub (Feb 4, 2025):

It would be fine if you could export an auth function that returns the better auth instance, but that is not the case as I mentioned in point 5 of my reproduction steps

It's my understanding that you can definetly make it a function that returns the instance, but then the CLI wont work.
My current workaround for that is the following:

// AppLoadContext is provided by react-router, and has the cloudflare object if configured for deploy on workers
export const getAuth = ({ context }: { context: AppLoadContext }) =>
  betterAuth({
    // rest of your config
    socialProviders: {
      google: {
        enabled: true,
        clientId: context.cloudflare.env.GOOGLE_CLIENT_ID as string, // <-- Access vars from context
        clientSecret: context.cloudflare.env.GOOGLE_CLIENT_SECRET as string,
      },
    },
  });

// Uncomment this if you need to run any better-auth CLI commands
// Run commands using `dotenv -e .dev.vars -- npx @better-auth/cli <command>`

// export const auth = betterAuth({
//    // rest of your config
//    socialProviders: {
//      google: {
//        enabled: true,
//        clientId: process.env.GOOGLE_CLIENT_ID as string, // <-- Access vars from env, and load dotenv in script
//        clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
//      },
//    },
// });

You do need to install dotenv-cli for this to work, and you can set up a script "better-auth": "dotenv -e .dev.vars -- npx @better-auth/cli", and run it using pnpm better-auth migrate, for example

Not the most comfortable workaround, but it gets the job done, and you don't really need to run the CLI that often.

This is what a sample loader/action would look like using the function approach:

export const loader = async ({ request, context }: Route.LoaderArgs) => {
  const auth = await getAuth({ context }).api.getSession({
    headers: request.headers,
  })
  if (!auth.session) {
    return redirect("/sign-up");
  }
  return { data: "Your data" };
};
<!-- gh-comment-id:2633594859 --> @Kylar13 commented on GitHub (Feb 4, 2025): > It would be fine if you could export an auth function that returns the better auth instance, but that is not the case as I mentioned in point 5 of my reproduction steps It's my understanding that you can definetly make it a function that returns the instance, but then the CLI wont work. My current workaround for that is the following: ``` // AppLoadContext is provided by react-router, and has the cloudflare object if configured for deploy on workers export const getAuth = ({ context }: { context: AppLoadContext }) => betterAuth({ // rest of your config socialProviders: { google: { enabled: true, clientId: context.cloudflare.env.GOOGLE_CLIENT_ID as string, // <-- Access vars from context clientSecret: context.cloudflare.env.GOOGLE_CLIENT_SECRET as string, }, }, }); // Uncomment this if you need to run any better-auth CLI commands // Run commands using `dotenv -e .dev.vars -- npx @better-auth/cli <command>` // export const auth = betterAuth({ // // rest of your config // socialProviders: { // google: { // enabled: true, // clientId: process.env.GOOGLE_CLIENT_ID as string, // <-- Access vars from env, and load dotenv in script // clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, // }, // }, // }); ``` You do need to install `dotenv-cli` for this to work, and you can set up a script `"better-auth": "dotenv -e .dev.vars -- npx @better-auth/cli",` and run it using `pnpm better-auth migrate`, for example Not the most comfortable workaround, but it gets the job done, and you don't really need to run the CLI that often. This is what a sample loader/action would look like using the function approach: ``` export const loader = async ({ request, context }: Route.LoaderArgs) => { const auth = await getAuth({ context }).api.getSession({ headers: request.headers, }) if (!auth.session) { return redirect("/sign-up"); } return { data: "Your data" }; }; ```
Author
Owner

@kumardeo commented on GitHub (Feb 4, 2025):

When I need my env exposed to process.env I use AsyncLocalStorage API to achieve this. The same approach is used in @cloudflare/next-on-pages or @opennext/cloudflare.

// src/worker.ts
const cloudflareALSPromise = Promise.all([import('node:async_hooks'), import('node:process') as unknown as Promise<{ env: object }>])
	.then(([{ AsyncLocalStorage }, { env }]) => {
		const cloudflareALS = new AsyncLocalStorage<{
			env: object;
			ctx: ExecutionContext;
			cf?: IncomingRequestCfProperties<unknown>;
		}>();

		Object.setPrototypeOf(
			env,
			new Proxy(
				{},
				{
					ownKeys: (target) => Reflect.ownKeys(cloudflareALS.getStore()?.env ?? env),
					getOwnPropertyDescriptor: (target, ...args) => Reflect.getOwnPropertyDescriptor(cloudflareALS.getStore()?.env ?? env, ...args),
					get: (target, property) => Reflect.get(cloudflareALS.getStore()?.env ?? env, property),
					set: (target, property, value) => Reflect.set(cloudflareALS.getStore()?.env ?? env, property, value),
				}
			)
		);

		return cloudflareALS;
	})
	.catch(() => {
		throw new Error('Failed to proxy process.env, nodejs_compat flag is not enabled');
	});

export default {
	async fetch(request, env, ctx) {
		return (await cloudflareALSPromise).run({ env, ctx, cf: request.cf }, async () => {
			return (await import('./app')).default.fetch(request, env, ctx);
		});
	},
	async scheduled(controller, env, ctx) {
		return (await cloudflareALSPromise).run({ env, ctx }, async () => {
			return (await import('./app')).default.scheduled(controller, env, ctx);
		});
	},
} satisfies ExportedHandler<Env>;

Warning

We need to dynamically import src/app.ts inside handlers as shown above

Tip

We will write our actual code in src/app.ts and not in src/worker.ts

// src/app.ts
import { Hono } from 'hono';
import { auth } from './lib/auth';

// you can access env here :)
console.log(process.env.BETTER_AUTH_SECRET);

const app = new Hono<{
	Bindings: Env;
}>().on(["POST", "GET"], "/api/auth/**", (c) => auth.handler(c.req.raw));

export default {
	fetch: app.fetch,
	async scheduled(controller, env, ctx) {},
} satisfies ExportedHandler<Env>;
// src/lib/auth.ts
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { db } from '../db/db';

export const auth = betterAuth({
	secret: process.env.BETTER_AUTH_SECRET,
	baseURL: process.env.BETTER_AUTH_URL,
	database: drizzleAdapter(db, { provider: 'sqlite' }),
});
// package.json
{
  // ...
  "scripts": {
    // ...
    "auth:generate": "pnpx @better-auth/cli@latest generate --output=src/db/schema/auth.ts --y"
  }
}
// wrangler.json
{
	"$schema": "node_modules/wrangler/config-schema.json",
	"name": "my-workers-app",
	"main": "src/worker.ts",
	"compatibility_date": "2025-01-29",
	"compatibility_flags": ["nodejs_compat"],
	"dev": {
		"port": 3000
	},
	"observability": {
		"enabled": true
	},
	"d1_databases": [{ "binding": "DB", "database_name": "xxxxx", "database_id": "xxxxx" }]
}

<!-- gh-comment-id:2634457761 --> @kumardeo commented on GitHub (Feb 4, 2025): When I need my `env` exposed to `process.env` I use `AsyncLocalStorage` API to achieve this. The same approach is used in `@cloudflare/next-on-pages` or `@opennext/cloudflare`. ```typescript // src/worker.ts const cloudflareALSPromise = Promise.all([import('node:async_hooks'), import('node:process') as unknown as Promise<{ env: object }>]) .then(([{ AsyncLocalStorage }, { env }]) => { const cloudflareALS = new AsyncLocalStorage<{ env: object; ctx: ExecutionContext; cf?: IncomingRequestCfProperties<unknown>; }>(); Object.setPrototypeOf( env, new Proxy( {}, { ownKeys: (target) => Reflect.ownKeys(cloudflareALS.getStore()?.env ?? env), getOwnPropertyDescriptor: (target, ...args) => Reflect.getOwnPropertyDescriptor(cloudflareALS.getStore()?.env ?? env, ...args), get: (target, property) => Reflect.get(cloudflareALS.getStore()?.env ?? env, property), set: (target, property, value) => Reflect.set(cloudflareALS.getStore()?.env ?? env, property, value), } ) ); return cloudflareALS; }) .catch(() => { throw new Error('Failed to proxy process.env, nodejs_compat flag is not enabled'); }); export default { async fetch(request, env, ctx) { return (await cloudflareALSPromise).run({ env, ctx, cf: request.cf }, async () => { return (await import('./app')).default.fetch(request, env, ctx); }); }, async scheduled(controller, env, ctx) { return (await cloudflareALSPromise).run({ env, ctx }, async () => { return (await import('./app')).default.scheduled(controller, env, ctx); }); }, } satisfies ExportedHandler<Env>; ``` > [!WARNING] > We need to dynamically import `src/app.ts` inside handlers as shown above > [!TIP] > We will write our actual code in `src/app.ts` and not in `src/worker.ts` ```typescript // src/app.ts import { Hono } from 'hono'; import { auth } from './lib/auth'; // you can access env here :) console.log(process.env.BETTER_AUTH_SECRET); const app = new Hono<{ Bindings: Env; }>().on(["POST", "GET"], "/api/auth/**", (c) => auth.handler(c.req.raw)); export default { fetch: app.fetch, async scheduled(controller, env, ctx) {}, } satisfies ExportedHandler<Env>; ``` ```typescript // src/lib/auth.ts import { betterAuth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { db } from '../db/db'; export const auth = betterAuth({ secret: process.env.BETTER_AUTH_SECRET, baseURL: process.env.BETTER_AUTH_URL, database: drizzleAdapter(db, { provider: 'sqlite' }), }); ``` ```jsonc // package.json { // ... "scripts": { // ... "auth:generate": "pnpx @better-auth/cli@latest generate --output=src/db/schema/auth.ts --y" } } ``` ```jsonc // wrangler.json { "$schema": "node_modules/wrangler/config-schema.json", "name": "my-workers-app", "main": "src/worker.ts", "compatibility_date": "2025-01-29", "compatibility_flags": ["nodejs_compat"], "dev": { "port": 3000 }, "observability": { "enabled": true }, "d1_databases": [{ "binding": "DB", "database_name": "xxxxx", "database_id": "xxxxx" }] } ```
Author
Owner

@enqqi commented on GitHub (Feb 4, 2025):

I managed to get to make it work! Here is how i did it:

This is a hono auth server deployed to cloudflare workers

auth.ts

import type { AppEnv } from "@/types/Env";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import {
  createAuthMiddleware,
  emailOTP,
  jwt,
  openAPI,
} from "better-auth/plugins";
import type { Context } from "hono";

// notice how this is a function that recieves the context
export const getAuth = (c: Context<AppEnv>) =>
  betterAuth({
    database: drizzleAdapter(c.get("db"), {
      provider: "pg",
    }),
    baseURL: c.env.AUTH_BASE_URL,
    secret: c.env.AUTH_SK,
    plugins: [
      openAPI(),
      jwt(),
      emailOTP({
        sendVerificationOTP: async (d, request) => {
          console.log(JSON.stringify(d, null, 2));
        },
      }),
    ],
    hooks: {
      after: createAuthMiddleware(async (ctx) => {
        console.log(ctx)
      }),
    },
    emailAndPassword: {
      enabled: true,
    },
    secondaryStorage: {
      get: c.env.AuthKV.get,
      set: (k, v) => c.env.AuthKV.put(k, v),
      delete: c.env.AuthKV.delete,
    },
    rateLimit: {
      window: 30, // time window in seconds
      max: 40, // max requests in the window
    },
    advanced: {
      cookiePrefix: "yummy",
      // https://www.better-auth.com/docs/integrations/hono#cross-domain-cookies
      crossSubDomainCookies: {
        enabled: true,
      },
      // https://www.better-auth.com/docs/concepts/database#id-generation
      // generateId: o => o.model
    },
    user: {
      modelName: "users",
      fields: {
        name: "full_name",
        emailVerified: "email_verified",
        createdAt: "created_at",
        image: "pfp_url",
        updatedAt: "updated_at",
      },
    },
    jwks: {
      modelName: "jwks",
      fields: {
        privateKey: "private_key",
        publicKey: "public_key",
        createdAt: "created_at",
      },
    },
    session: {
      modelName: "auth_sessions",
      fields: {
        userId: "user_id",
        ipAddress: "ip",
        userAgent: "user_agent",
        expiresAt: "expires_at",
        createdAt: "created_at",
        updatedAt: "updated_at",
      },
    },
    account: {
      modelName: "auth_providers",
      fields: {
        userId: "user_id",
        createdAt: "created_at",
        updatedAt: "updated_at",
        accessTokenExpiresAt: "access_token_exp_at",
        refreshTokenExpiresAt: "refresh_token_exp_at",
        accessToken: "access_token",
        refreshToken: "refresh_token",
        providerId: "sso_provider_id",
        accountId: "sso_account_id",
      },
    },
    verification: {
      modelName: "auth_verifications",
      fields: {
        expiresAt: "expires_at",
        createdAt: "created_at",
        updatedAt: "updated_at",
      },
    },
  });

Then in the routes:

import { Hono } from "hono";
import { cors } from "hono/cors";
import { contextMiddleware } from "./mw/ctx";
import type { AppEnv } from "./types/Env";
import { contextStorage } from "hono/context-storage";
import { getAuth } from "./auth";

const app = new Hono<AppEnv>();

app.use("*", contextMiddleware);

app.use(
  "/api/auth/**",
  cors({
    origin: "http://localhost:8787", // replace with your origin
    allowHeaders: ["Content-Type", "Authorization"],
    allowMethods: ["POST", "GET", "OPTIONS"],
    exposeHeaders: ["Content-Length"],
    maxAge: 600,
    credentials: true,
  }),
);

app.on(["POST", "GET"], "/api/auth/**", (c) => getAuth(c).handler(c.req.raw));

// this is optional since you can get the session from better-auth endpoint
app.get("/session", async (c) => {
  const session = c.get("session"); // ctx.ts to see how this is set
  const user = c.get("user");

  if (!user) return c.body(null, 401);

  return c.json({
    session,
    user,
  });
});

export default app;

ctx.ts

import { getAuth } from "@/auth";
import type { AppEnv } from "@/types/Env";
import { getConnection } from "@cpr/utils";
import { createMiddleware } from "hono/factory";

export const contextMiddleware = createMiddleware<AppEnv>(async (c, next) => {
  // `getConnection` is an internal pkg from my monorepo
  // returns a postgres-js drizzle instance
  const db = getConnection(c.env.DB_URL);
  
  c.set("db", db); // set db to access it in `getAuth`

  // you don't really need any of this last part
  const session = await getAuth(c).api.getSession({
    headers: c.req.raw.headers,
  });

  if (!session) {
    c.set("user", null);
    c.set("session", null);
    return next();
  }

  c.set("user", session.user);
  c.set("session", session.session);
  await next()
});

Caveats:

As @Kylar13 pointed out, the CLI won’t work in this case because it relies on the auth singleton. If the maintainers are open to it, I’d be happy to submit a PR to make functions compatible with the CLI! :)

That said, not using the CLI isn’t a huge issue—you can simply check the documentation for the plugin you need and manually create or update the schema. If you’re renaming columns, just be sure to map the correct fields in the betterAuth config.

I’m still running into some issues with React Native, but they don’t seem related to this. I’ll need to do some more testing to confirm if there’s actually a problem.

<!-- gh-comment-id:2634621495 --> @enqqi commented on GitHub (Feb 4, 2025): I managed to get to make it work! Here is how i did it: This is a hono auth server deployed to cloudflare workers auth.ts ```typescript import type { AppEnv } from "@/types/Env"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { createAuthMiddleware, emailOTP, jwt, openAPI, } from "better-auth/plugins"; import type { Context } from "hono"; // notice how this is a function that recieves the context export const getAuth = (c: Context<AppEnv>) => betterAuth({ database: drizzleAdapter(c.get("db"), { provider: "pg", }), baseURL: c.env.AUTH_BASE_URL, secret: c.env.AUTH_SK, plugins: [ openAPI(), jwt(), emailOTP({ sendVerificationOTP: async (d, request) => { console.log(JSON.stringify(d, null, 2)); }, }), ], hooks: { after: createAuthMiddleware(async (ctx) => { console.log(ctx) }), }, emailAndPassword: { enabled: true, }, secondaryStorage: { get: c.env.AuthKV.get, set: (k, v) => c.env.AuthKV.put(k, v), delete: c.env.AuthKV.delete, }, rateLimit: { window: 30, // time window in seconds max: 40, // max requests in the window }, advanced: { cookiePrefix: "yummy", // https://www.better-auth.com/docs/integrations/hono#cross-domain-cookies crossSubDomainCookies: { enabled: true, }, // https://www.better-auth.com/docs/concepts/database#id-generation // generateId: o => o.model }, user: { modelName: "users", fields: { name: "full_name", emailVerified: "email_verified", createdAt: "created_at", image: "pfp_url", updatedAt: "updated_at", }, }, jwks: { modelName: "jwks", fields: { privateKey: "private_key", publicKey: "public_key", createdAt: "created_at", }, }, session: { modelName: "auth_sessions", fields: { userId: "user_id", ipAddress: "ip", userAgent: "user_agent", expiresAt: "expires_at", createdAt: "created_at", updatedAt: "updated_at", }, }, account: { modelName: "auth_providers", fields: { userId: "user_id", createdAt: "created_at", updatedAt: "updated_at", accessTokenExpiresAt: "access_token_exp_at", refreshTokenExpiresAt: "refresh_token_exp_at", accessToken: "access_token", refreshToken: "refresh_token", providerId: "sso_provider_id", accountId: "sso_account_id", }, }, verification: { modelName: "auth_verifications", fields: { expiresAt: "expires_at", createdAt: "created_at", updatedAt: "updated_at", }, }, }); ``` Then in the routes: ```typescript import { Hono } from "hono"; import { cors } from "hono/cors"; import { contextMiddleware } from "./mw/ctx"; import type { AppEnv } from "./types/Env"; import { contextStorage } from "hono/context-storage"; import { getAuth } from "./auth"; const app = new Hono<AppEnv>(); app.use("*", contextMiddleware); app.use( "/api/auth/**", cors({ origin: "http://localhost:8787", // replace with your origin allowHeaders: ["Content-Type", "Authorization"], allowMethods: ["POST", "GET", "OPTIONS"], exposeHeaders: ["Content-Length"], maxAge: 600, credentials: true, }), ); app.on(["POST", "GET"], "/api/auth/**", (c) => getAuth(c).handler(c.req.raw)); // this is optional since you can get the session from better-auth endpoint app.get("/session", async (c) => { const session = c.get("session"); // ctx.ts to see how this is set const user = c.get("user"); if (!user) return c.body(null, 401); return c.json({ session, user, }); }); export default app; ``` ctx.ts ```typescript import { getAuth } from "@/auth"; import type { AppEnv } from "@/types/Env"; import { getConnection } from "@cpr/utils"; import { createMiddleware } from "hono/factory"; export const contextMiddleware = createMiddleware<AppEnv>(async (c, next) => { // `getConnection` is an internal pkg from my monorepo // returns a postgres-js drizzle instance const db = getConnection(c.env.DB_URL); c.set("db", db); // set db to access it in `getAuth` // you don't really need any of this last part const session = await getAuth(c).api.getSession({ headers: c.req.raw.headers, }); if (!session) { c.set("user", null); c.set("session", null); return next(); } c.set("user", session.user); c.set("session", session.session); await next() }); ``` Caveats: As @Kylar13 pointed out, the CLI won’t work in this case because it relies on the auth singleton. If the maintainers are open to it, I’d be happy to submit a PR to make functions compatible with the CLI! :) That said, not using the CLI isn’t a huge issue—you can simply check the documentation for the plugin you need and manually create or update the schema. If you’re renaming columns, just be sure to map the correct fields in the `betterAuth` config. I’m still running into some issues with React Native, but they don’t seem related to this. I’ll need to do some more testing to confirm if there’s actually a problem.
Author
Owner

@abegehr commented on GitHub (Feb 10, 2025):

@enzojs, thank you for sharing your solution! I have a similar function-setup of better-auth on Hono. However, I'm using the bearer plugin for Bearer token authentication. With the bearer token plugin enabled my request fail with TypeError: Can't modify immutable headers. - did you find a fix for that part of the issue?

<!-- gh-comment-id:2646894774 --> @abegehr commented on GitHub (Feb 10, 2025): @enzojs, thank you for sharing your solution! I have a similar function-setup of better-auth on Hono. However, I'm using the bearer plugin for Bearer token authentication. With the bearer token plugin enabled my request fail with `TypeError: Can't modify immutable headers.` - did you find a fix for that part of the issue?
Author
Owner

@samducker commented on GitHub (Feb 23, 2025):

I am doing a similar approach to you @enzojs however I'm struggling to get the type inference doing it this way. Did you have any advice on getting these inferred types out so you can share them with your client.

Even better if you know how to get the better auth routes into the hono RPC?

I get this error without a manual type assertion

The inferred type of 'createAuth' cannot be named without a reference to '../../../../../node_modules/better-auth/dist/index-Y--3ocl8'. This is likely not portable. A type annotation is necessary.ts(2742)

If I do : ReturnType then my openapi plugin and inferred types don't work correctly.

Probably this issue https://github.com/better-auth/better-auth/issues/1391

<!-- gh-comment-id:2677086552 --> @samducker commented on GitHub (Feb 23, 2025): I am doing a similar approach to you @enzojs however I'm struggling to get the type inference doing it this way. Did you have any advice on getting these inferred types out so you can share them with your client. Even better if you know how to get the better auth routes into the hono RPC? I get this error without a manual type assertion ``` The inferred type of 'createAuth' cannot be named without a reference to '../../../../../node_modules/better-auth/dist/index-Y--3ocl8'. This is likely not portable. A type annotation is necessary.ts(2742) ``` If I do : ReturnType<typeof betterAuth> then my openapi plugin and inferred types don't work correctly. Probably this issue https://github.com/better-auth/better-auth/issues/1391
Author
Owner

@enqqi commented on GitHub (Feb 25, 2025):

I'm actually running a monorepo with an Expo app, SvelteKit, Hono, and using Bun as the package manager. The monorepo is set up with npm (Bun) workspaces, just a basic setup.

I think your issue might be related to how your project is structured. You may need to set up a monorepo where both your Better Auth server and clients share a common package for the types. @samducker

<!-- gh-comment-id:2680558154 --> @enqqi commented on GitHub (Feb 25, 2025): I'm actually running a monorepo with an Expo app, SvelteKit, Hono, and using Bun as the package manager. The monorepo is set up with npm (Bun) workspaces, just a basic setup. I think your issue might be related to how your project is structured. You may need to set up a monorepo where both your Better Auth server and clients share a common package for the types. @samducker
Author
Owner

@Fawwaz-2009 commented on GitHub (Mar 10, 2025):

hey @enzojs thanks for sharing your solution. I tried something similar to your solution but keep getting BASE_URL is needed error , any idea what maybe causing this?

export const getAuth = ({ BETTER_AUTH_SECRET, drizzleDB }: { BETTER_AUTH_SECRET: string; drizzleDB: DrizzleDB }) =>
  betterAuth({
    secret: BETTER_AUTH_SECRET,
    baseUrl: "http://localhost:8787",
    trustedOrigins: ["http://localhost:3000", "http://localhost:8787"],
    advanced: {
      crossSubDomainCookies: {
        enabled: true,
      },
    },
    database: drizzleAdapter(drizzleDB, {
      provider: "sqlite",
      schema,
    }),
    emailAndPassword: {
      enabled: true,
    },
  });

Image

<!-- gh-comment-id:2710757769 --> @Fawwaz-2009 commented on GitHub (Mar 10, 2025): hey @enzojs thanks for sharing your solution. I tried something similar to your solution but keep getting BASE_URL is needed error , any idea what maybe causing this? ```ts export const getAuth = ({ BETTER_AUTH_SECRET, drizzleDB }: { BETTER_AUTH_SECRET: string; drizzleDB: DrizzleDB }) => betterAuth({ secret: BETTER_AUTH_SECRET, baseUrl: "http://localhost:8787", trustedOrigins: ["http://localhost:3000", "http://localhost:8787"], advanced: { crossSubDomainCookies: { enabled: true, }, }, database: drizzleAdapter(drizzleDB, { provider: "sqlite", schema, }), emailAndPassword: { enabled: true, }, }); ``` ![Image](https://github.com/user-attachments/assets/206816fa-6032-46f1-bc38-ae11f39aa1c9)
Author
Owner

@Fawwaz-2009 commented on GitHub (Mar 10, 2025):

nvm actually it turns out that needed to change baseUrl to baseURL :)

<!-- gh-comment-id:2710772673 --> @Fawwaz-2009 commented on GitHub (Mar 10, 2025): nvm actually it turns out that needed to change `baseUrl` to `baseURL` :)
Author
Owner

@cihadaydemir commented on GitHub (Mar 13, 2025):

I managed to get to make it work! Here is how i did it:

This is a hono auth server deployed to cloudflare workers

auth.ts

import type { AppEnv } from "@/types/Env";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import {
createAuthMiddleware,
emailOTP,
jwt,
openAPI,
} from "better-auth/plugins";
import type { Context } from "hono";

// notice how this is a function that recieves the context
export const getAuth = (c: Context) =>
betterAuth({
database: drizzleAdapter(c.get("db"), {
provider: "pg",
}),
baseURL: c.env.AUTH_BASE_URL,
secret: c.env.AUTH_SK,
plugins: [
openAPI(),
jwt(),
emailOTP({
sendVerificationOTP: async (d, request) => {
console.log(JSON.stringify(d, null, 2));
},
}),
],
hooks: {
after: createAuthMiddleware(async (ctx) => {
console.log(ctx)
}),
},
emailAndPassword: {
enabled: true,
},
secondaryStorage: {
get: c.env.AuthKV.get,
set: (k, v) => c.env.AuthKV.put(k, v),
delete: c.env.AuthKV.delete,
},
rateLimit: {
window: 30, // time window in seconds
max: 40, // max requests in the window
},
advanced: {
cookiePrefix: "yummy",
// https://www.better-auth.com/docs/integrations/hono#cross-domain-cookies
crossSubDomainCookies: {
enabled: true,
},
// https://www.better-auth.com/docs/concepts/database#id-generation
// generateId: o => o.model
},
user: {
modelName: "users",
fields: {
name: "full_name",
emailVerified: "email_verified",
createdAt: "created_at",
image: "pfp_url",
updatedAt: "updated_at",
},
},
jwks: {
modelName: "jwks",
fields: {
privateKey: "private_key",
publicKey: "public_key",
createdAt: "created_at",
},
},
session: {
modelName: "auth_sessions",
fields: {
userId: "user_id",
ipAddress: "ip",
userAgent: "user_agent",
expiresAt: "expires_at",
createdAt: "created_at",
updatedAt: "updated_at",
},
},
account: {
modelName: "auth_providers",
fields: {
userId: "user_id",
createdAt: "created_at",
updatedAt: "updated_at",
accessTokenExpiresAt: "access_token_exp_at",
refreshTokenExpiresAt: "refresh_token_exp_at",
accessToken: "access_token",
refreshToken: "refresh_token",
providerId: "sso_provider_id",
accountId: "sso_account_id",
},
},
verification: {
modelName: "auth_verifications",
fields: {
expiresAt: "expires_at",
createdAt: "created_at",
updatedAt: "updated_at",
},
},
});
Then in the routes:

import { Hono } from "hono";
import { cors } from "hono/cors";
import { contextMiddleware } from "./mw/ctx";
import type { AppEnv } from "./types/Env";
import { contextStorage } from "hono/context-storage";
import { getAuth } from "./auth";

const app = new Hono();

app.use("*", contextMiddleware);

app.use(
"/api/auth/**",
cors({
origin: "http://localhost:8787", // replace with your origin
allowHeaders: ["Content-Type", "Authorization"],
allowMethods: ["POST", "GET", "OPTIONS"],
exposeHeaders: ["Content-Length"],
maxAge: 600,
credentials: true,
}),
);

app.on(["POST", "GET"], "/api/auth/**", (c) => getAuth(c).handler(c.req.raw));

// this is optional since you can get the session from better-auth endpoint
app.get("/session", async (c) => {
const session = c.get("session"); // ctx.ts to see how this is set
const user = c.get("user");

if (!user) return c.body(null, 401);

return c.json({
session,
user,
});
});

export default app;
ctx.ts

import { getAuth } from "@/auth";
import type { AppEnv } from "@/types/Env";
import { getConnection } from "@cpr/utils";
import { createMiddleware } from "hono/factory";

export const contextMiddleware = createMiddleware(async (c, next) => {
// getConnection is an internal pkg from my monorepo
// returns a postgres-js drizzle instance
const db = getConnection(c.env.DB_URL);

c.set("db", db); // set db to access it in getAuth

// you don't really need any of this last part
const session = await getAuth(c).api.getSession({
headers: c.req.raw.headers,
});

if (!session) {
c.set("user", null);
c.set("session", null);
return next();
}

c.set("user", session.user);
c.set("session", session.session);
await next()
});
Caveats:

As @Kylar13 pointed out, the CLI won’t work in this case because it relies on the auth singleton. If the maintainers are open to it, I’d be happy to submit a PR to make functions compatible with the CLI! :)

That said, not using the CLI isn’t a huge issue—you can simply check the documentation for the plugin you need and manually create or update the schema. If you’re renaming columns, just be sure to map the correct fields in the betterAuth config.

I’m still running into some issues with React Native, but they don’t seem related to this. I’ll need to do some more testing to confirm if there’s actually a problem.

Hi @enzojs,
first of all big thanks for your contributions so far.
I've been struggeling with exactly the same thing for days now and also tried out different approaches to get it running.
I tried your most recent approach at my project, but keep getting a cors error, despite having set up a cors middleware.
For me it seems like better-auth is still breaking and thats causing the cors error.

When signing in via social providers I get following error:
[wrangler:inf] OPTIONS /api/auth/sign-in/social 500 Internal Server Error (6ms)

Did you maybe have experience something similar or have an idea what the issue could be?
Thanks

<!-- gh-comment-id:2722873815 --> @cihadaydemir commented on GitHub (Mar 13, 2025): > I managed to get to make it work! Here is how i did it: > > This is a hono auth server deployed to cloudflare workers > > auth.ts > > import type { AppEnv } from "@/types/Env"; > import { betterAuth } from "better-auth"; > import { drizzleAdapter } from "better-auth/adapters/drizzle"; > import { > createAuthMiddleware, > emailOTP, > jwt, > openAPI, > } from "better-auth/plugins"; > import type { Context } from "hono"; > > // notice how this is a function that recieves the context > export const getAuth = (c: Context<AppEnv>) => > betterAuth({ > database: drizzleAdapter(c.get("db"), { > provider: "pg", > }), > baseURL: c.env.AUTH_BASE_URL, > secret: c.env.AUTH_SK, > plugins: [ > openAPI(), > jwt(), > emailOTP({ > sendVerificationOTP: async (d, request) => { > console.log(JSON.stringify(d, null, 2)); > }, > }), > ], > hooks: { > after: createAuthMiddleware(async (ctx) => { > console.log(ctx) > }), > }, > emailAndPassword: { > enabled: true, > }, > secondaryStorage: { > get: c.env.AuthKV.get, > set: (k, v) => c.env.AuthKV.put(k, v), > delete: c.env.AuthKV.delete, > }, > rateLimit: { > window: 30, // time window in seconds > max: 40, // max requests in the window > }, > advanced: { > cookiePrefix: "yummy", > // https://www.better-auth.com/docs/integrations/hono#cross-domain-cookies > crossSubDomainCookies: { > enabled: true, > }, > // https://www.better-auth.com/docs/concepts/database#id-generation > // generateId: o => o.model > }, > user: { > modelName: "users", > fields: { > name: "full_name", > emailVerified: "email_verified", > createdAt: "created_at", > image: "pfp_url", > updatedAt: "updated_at", > }, > }, > jwks: { > modelName: "jwks", > fields: { > privateKey: "private_key", > publicKey: "public_key", > createdAt: "created_at", > }, > }, > session: { > modelName: "auth_sessions", > fields: { > userId: "user_id", > ipAddress: "ip", > userAgent: "user_agent", > expiresAt: "expires_at", > createdAt: "created_at", > updatedAt: "updated_at", > }, > }, > account: { > modelName: "auth_providers", > fields: { > userId: "user_id", > createdAt: "created_at", > updatedAt: "updated_at", > accessTokenExpiresAt: "access_token_exp_at", > refreshTokenExpiresAt: "refresh_token_exp_at", > accessToken: "access_token", > refreshToken: "refresh_token", > providerId: "sso_provider_id", > accountId: "sso_account_id", > }, > }, > verification: { > modelName: "auth_verifications", > fields: { > expiresAt: "expires_at", > createdAt: "created_at", > updatedAt: "updated_at", > }, > }, > }); > Then in the routes: > > import { Hono } from "hono"; > import { cors } from "hono/cors"; > import { contextMiddleware } from "./mw/ctx"; > import type { AppEnv } from "./types/Env"; > import { contextStorage } from "hono/context-storage"; > import { getAuth } from "./auth"; > > const app = new Hono<AppEnv>(); > > app.use("*", contextMiddleware); > > app.use( > "/api/auth/**", > cors({ > origin: "http://localhost:8787", // replace with your origin > allowHeaders: ["Content-Type", "Authorization"], > allowMethods: ["POST", "GET", "OPTIONS"], > exposeHeaders: ["Content-Length"], > maxAge: 600, > credentials: true, > }), > ); > > app.on(["POST", "GET"], "/api/auth/**", (c) => getAuth(c).handler(c.req.raw)); > > // this is optional since you can get the session from better-auth endpoint > app.get("/session", async (c) => { > const session = c.get("session"); // ctx.ts to see how this is set > const user = c.get("user"); > > if (!user) return c.body(null, 401); > > return c.json({ > session, > user, > }); > }); > > export default app; > ctx.ts > > import { getAuth } from "@/auth"; > import type { AppEnv } from "@/types/Env"; > import { getConnection } from "@cpr/utils"; > import { createMiddleware } from "hono/factory"; > > export const contextMiddleware = createMiddleware<AppEnv>(async (c, next) => { > // `getConnection` is an internal pkg from my monorepo > // returns a postgres-js drizzle instance > const db = getConnection(c.env.DB_URL); > > c.set("db", db); // set db to access it in `getAuth` > > // you don't really need any of this last part > const session = await getAuth(c).api.getSession({ > headers: c.req.raw.headers, > }); > > if (!session) { > c.set("user", null); > c.set("session", null); > return next(); > } > > c.set("user", session.user); > c.set("session", session.session); > await next() > }); > Caveats: > > As [@Kylar13](https://github.com/Kylar13) pointed out, the CLI won’t work in this case because it relies on the auth singleton. If the maintainers are open to it, I’d be happy to submit a PR to make functions compatible with the CLI! :) > > That said, not using the CLI isn’t a huge issue—you can simply check the documentation for the plugin you need and manually create or update the schema. If you’re renaming columns, just be sure to map the correct fields in the `betterAuth` config. > > I’m still running into some issues with React Native, but they don’t seem related to this. I’ll need to do some more testing to confirm if there’s actually a problem. Hi @enzojs, first of all big thanks for your contributions so far. I've been struggeling with exactly the same thing for days now and also tried out different approaches to get it running. I tried your most recent approach at my project, but keep getting a cors error, despite having set up a cors middleware. For me it seems like better-auth is still breaking and thats causing the cors error. When signing in via social providers I get following error: [wrangler:inf] OPTIONS /api/auth/sign-in/social 500 Internal Server Error (6ms) Did you maybe have experience something similar or have an idea what the issue could be? Thanks
Author
Owner

@Fawwaz-2009 commented on GitHub (Mar 15, 2025):

not sure if our problems are related but I have the two apps server(hono) and frontend(next.js). server deployed to cloudlfare and frontend on vercel, while everything works perfectly locally. the authentication fails on prod, not 100% sure if it's a cors issue (there were cors errors but they are gone now) after a lot of debugging it's seems the cookie either doesn't get set or just not being sent by the frontend. during my debugging I had to hook into the before and after in better auth config and I can see that the token is not on the cookie, it never there

<!-- gh-comment-id:2726654664 --> @Fawwaz-2009 commented on GitHub (Mar 15, 2025): not sure if our problems are related but I have the two apps server(hono) and frontend(next.js). server deployed to cloudlfare and frontend on vercel, while everything works perfectly locally. the authentication fails on prod, not 100% sure if it's a cors issue (there were cors errors but they are gone now) after a lot of debugging it's seems the cookie either doesn't get set or just not being sent by the frontend. during my debugging I had to hook into the before and after in better auth config and I can see that the token is not on the cookie, it never there
Author
Owner

@cihadaydemir commented on GitHub (Mar 15, 2025):

Thanks for your respond.
Sounds like a similar problem. I also debugged yesterday and saw something strange happening with getSession() function in my middleware so my guess is that something is wrong with the header that’s been sent to Hono.
Will definitely check out the before and after hooks as well.
It’s been days now I can’t get it working 🥲

<!-- gh-comment-id:2726723477 --> @cihadaydemir commented on GitHub (Mar 15, 2025): Thanks for your respond. Sounds like a similar problem. I also debugged yesterday and saw something strange happening with getSession() function in my middleware so my guess is that something is wrong with the header that’s been sent to Hono. Will definitely check out the before and after hooks as well. It’s been days now I can’t get it working 🥲
Author
Owner

@Fawwaz-2009 commented on GitHub (Mar 16, 2025):

So, I got better auth working with both next.js and tanstack setups. Might drop a template later if anyone's interested.

I found there is potentially unsolvable limitation when using a Next.js frontend with a Hono API deployed to Cloudflare Workers. You basically can't use server components or server actions with better auth in this setup.

The issue is that you need the auth server client (the one from the Hono app) in your server components, but that client needs the Cloudflare ENV variables to initialize and access the DB. So you end up stuck in this weird spot where things don't quite connect.

Also, I don't know why using the auth client from the client-side of BetterAuth (the one in next.js app) does not work inside the server component, even though when I pass to it the headers, I couldn't figure out why this one doesn't work.

this might related to the issue

001126ba5e/packages/better-auth/src/integrations/next-js.ts (L21-L64)

<!-- gh-comment-id:2727125102 --> @Fawwaz-2009 commented on GitHub (Mar 16, 2025): So, I got better auth working with both [next.js](https://www.f-stack.dev/auth/login) and [tanstack](https://tanstack.f-stack.dev/auth/login) setups. Might drop a template later if anyone's interested. I found there is potentially unsolvable limitation when using a Next.js frontend with a Hono API deployed to Cloudflare Workers. You basically can't use server components or server actions with better auth in this setup. The issue is that you need the auth server client (the one from the Hono app) in your server components, but that client needs the Cloudflare ENV variables to initialize and access the DB. So you end up stuck in this weird spot where things don't quite connect. Also, I don't know why using the auth client from the client-side of BetterAuth (the one in next.js app) does not work inside the server component, even though when I pass to it the headers, I couldn't figure out why this one doesn't work. this might related to the issue https://github.com/better-auth/better-auth/blob/001126ba5ea27e94bb59c7def6d407345b623dcf/packages/better-auth/src/integrations/next-js.ts#L21-L64
Author
Owner

@cihadaydemir commented on GitHub (Mar 16, 2025):

Okay that sounds very strange.
In my recent Next.js project I was able to get it running very smoothly but I didn’t deploy on Cloudflare and also didn’t use Hono for the api’s.
It seems like that Hono on CF Workers is the problem.

<!-- gh-comment-id:2727334438 --> @cihadaydemir commented on GitHub (Mar 16, 2025): Okay that sounds very strange. In my recent Next.js project I was able to get it running very smoothly but I didn’t deploy on Cloudflare and also didn’t use Hono for the api’s. It seems like that Hono on CF Workers is the problem.
Author
Owner

@Fawwaz-2009 commented on GitHub (Mar 17, 2025):

for anyone looking for example here is an example with two different frontend, next.js and tanstack, the nextjs example is not perfect as the server side not working but the tanstack one should have no issues
https://github.com/Fawwaz-2009/cloudflare-api-and-separate-frontend

<!-- gh-comment-id:2728550190 --> @Fawwaz-2009 commented on GitHub (Mar 17, 2025): for anyone looking for example here is an example with two different frontend, [next.js](https://www.f-stack.dev) and [tanstack](https://tanstack.f-stack.dev), the nextjs example is not perfect as the server side not working but the tanstack one should have no issues https://github.com/Fawwaz-2009/cloudflare-api-and-separate-frontend
Author
Owner

@cihadaydemir commented on GitHub (Mar 17, 2025):

for anyone looking for example here is an example with two different frontend, next.js and tanstack, the nextjs example is not perfect as the server side not working but the tanstack one should have no issues https://github.com/Fawwaz-2009/cloudflare-api-and-separate-frontend

@Fawwaz-2009 First of all, thank you very much for helping me to solve my problems.
I still have some problems: when signing in via social providers I get an error that the callback URL is wrong, even though it is correct. My FE is running on port 3001 and my callback URL on the signin function is like this
callbackURL: window.location.origin Any idea why this error might occur?

<!-- gh-comment-id:2731011104 --> @cihadaydemir commented on GitHub (Mar 17, 2025): > for anyone looking for example here is an example with two different frontend, [next.js](https://www.f-stack.dev) and [tanstack](https://tanstack.f-stack.dev), the nextjs example is not perfect as the server side not working but the tanstack one should have no issues https://github.com/Fawwaz-2009/cloudflare-api-and-separate-frontend @Fawwaz-2009 First of all, thank you very much for helping me to solve my problems. I still have some problems: when signing in via social providers I get an error that the callback URL is wrong, even though it is correct. My FE is running on port 3001 and my callback URL on the signin function is like this `callbackURL: window.location.origin` Any idea why this error might occur?
Author
Owner

@enqqi commented on GitHub (Mar 17, 2025):

I suggest you open a thread in the discord. I'm sure you'll get a faster response there. Furthermore, I will take a deeper look to your issue once I get home. @cihadaydemir

<!-- gh-comment-id:2731019320 --> @enqqi commented on GitHub (Mar 17, 2025): I suggest you open a thread in the discord. I'm sure you'll get a faster response there. Furthermore, I will take a deeper look to your issue once I get home. @cihadaydemir
Author
Owner

@molvqingtai commented on GitHub (Jun 30, 2025):

I managed to get to make it work! Here is how i did it:

This is a hono auth server deployed to cloudflare workers

auth.ts

import type { AppEnv } from "@/types/Env";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import {
createAuthMiddleware,
emailOTP,
jwt,
openAPI,
} from "better-auth/plugins";
import type { Context } from "hono";

// notice how this is a function that recieves the context
export const getAuth = (c: Context) =>
betterAuth({
database: drizzleAdapter(c.get("db"), {
provider: "pg",
}),
baseURL: c.env.AUTH_BASE_URL,
secret: c.env.AUTH_SK,
plugins: [
openAPI(),
jwt(),
emailOTP({
sendVerificationOTP: async (d, request) => {
console.log(JSON.stringify(d, null, 2));
},
}),
],
hooks: {
after: createAuthMiddleware(async (ctx) => {
console.log(ctx)
}),
},
emailAndPassword: {
enabled: true,
},
secondaryStorage: {
get: c.env.AuthKV.get,
set: (k, v) => c.env.AuthKV.put(k, v),
delete: c.env.AuthKV.delete,
},
rateLimit: {
window: 30, // time window in seconds
max: 40, // max requests in the window
},
advanced: {
cookiePrefix: "yummy",
// https://www.better-auth.com/docs/integrations/hono#cross-domain-cookies
crossSubDomainCookies: {
enabled: true,
},
// https://www.better-auth.com/docs/concepts/database#id-generation
// generateId: o => o.model
},
user: {
modelName: "users",
fields: {
name: "full_name",
emailVerified: "email_verified",
createdAt: "created_at",
image: "pfp_url",
updatedAt: "updated_at",
},
},
jwks: {
modelName: "jwks",
fields: {
privateKey: "private_key",
publicKey: "public_key",
createdAt: "created_at",
},
},
session: {
modelName: "auth_sessions",
fields: {
userId: "user_id",
ipAddress: "ip",
userAgent: "user_agent",
expiresAt: "expires_at",
createdAt: "created_at",
updatedAt: "updated_at",
},
},
account: {
modelName: "auth_providers",
fields: {
userId: "user_id",
createdAt: "created_at",
updatedAt: "updated_at",
accessTokenExpiresAt: "access_token_exp_at",
refreshTokenExpiresAt: "refresh_token_exp_at",
accessToken: "access_token",
refreshToken: "refresh_token",
providerId: "sso_provider_id",
accountId: "sso_account_id",
},
},
verification: {
modelName: "auth_verifications",
fields: {
expiresAt: "expires_at",
createdAt: "created_at",
updatedAt: "updated_at",
},
},
});
Then in the routes:

import { Hono } from "hono";
import { cors } from "hono/cors";
import { contextMiddleware } from "./mw/ctx";
import type { AppEnv } from "./types/Env";
import { contextStorage } from "hono/context-storage";
import { getAuth } from "./auth";

const app = new Hono();

app.use("*", contextMiddleware);

app.use(
"/api/auth/**",
cors({
origin: "http://localhost:8787", // replace with your origin
allowHeaders: ["Content-Type", "Authorization"],
allowMethods: ["POST", "GET", "OPTIONS"],
exposeHeaders: ["Content-Length"],
maxAge: 600,
credentials: true,
}),
);

app.on(["POST", "GET"], "/api/auth/**", (c) => getAuth(c).handler(c.req.raw));

// this is optional since you can get the session from better-auth endpoint
app.get("/session", async (c) => {
const session = c.get("session"); // ctx.ts to see how this is set
const user = c.get("user");

if (!user) return c.body(null, 401);

return c.json({
session,
user,
});
});

export default app;
ctx.ts

import { getAuth } from "@/auth";
import type { AppEnv } from "@/types/Env";
import { getConnection } from "@cpr/utils";
import { createMiddleware } from "hono/factory";

export const contextMiddleware = createMiddleware(async (c, next) => {
// getConnection is an internal pkg from my monorepo
// returns a postgres-js drizzle instance
const db = getConnection(c.env.DB_URL);

c.set("db", db); // set db to access it in getAuth

// you don't really need any of this last part
const session = await getAuth(c).api.getSession({
headers: c.req.raw.headers,
});

if (!session) {
c.set("user", null);
c.set("session", null);
return next();
}

c.set("user", session.user);
c.set("session", session.session);
await next()
});
Caveats:

As @Kylar13 pointed out, the CLI won’t work in this case because it relies on the auth singleton. If the maintainers are open to it, I’d be happy to submit a PR to make functions compatible with the CLI! :)

That said, not using the CLI isn’t a huge issue—you can simply check the documentation for the plugin you need and manually create or update the schema. If you’re renaming columns, just be sure to map the correct fields in the betterAuth config.

I’m still running into some issues with React Native, but they don’t seem related to this. I’ll need to do some more testing to confirm if there’s actually a problem.

I spent a day studying this issue, I tried to modify the core of the cli to load a function, but I still got an error


ERROR: `getCloudflareContext` has been called without having called `initOpenNextCloudflareForDev` from the Next.js config file.

This has nothing to do with better-auth, although I added initOpenNextCloudflareForDev() to the top of auth.ts, it still doesn't work, it seems that initOpenNextCloudflareForDev only supports starting with next dev.

<!-- gh-comment-id:3020371246 --> @molvqingtai commented on GitHub (Jun 30, 2025): > I managed to get to make it work! Here is how i did it: > > This is a hono auth server deployed to cloudflare workers > > auth.ts > > import type { AppEnv } from "@/types/Env"; > import { betterAuth } from "better-auth"; > import { drizzleAdapter } from "better-auth/adapters/drizzle"; > import { > createAuthMiddleware, > emailOTP, > jwt, > openAPI, > } from "better-auth/plugins"; > import type { Context } from "hono"; > > // notice how this is a function that recieves the context > export const getAuth = (c: Context<AppEnv>) => > betterAuth({ > database: drizzleAdapter(c.get("db"), { > provider: "pg", > }), > baseURL: c.env.AUTH_BASE_URL, > secret: c.env.AUTH_SK, > plugins: [ > openAPI(), > jwt(), > emailOTP({ > sendVerificationOTP: async (d, request) => { > console.log(JSON.stringify(d, null, 2)); > }, > }), > ], > hooks: { > after: createAuthMiddleware(async (ctx) => { > console.log(ctx) > }), > }, > emailAndPassword: { > enabled: true, > }, > secondaryStorage: { > get: c.env.AuthKV.get, > set: (k, v) => c.env.AuthKV.put(k, v), > delete: c.env.AuthKV.delete, > }, > rateLimit: { > window: 30, // time window in seconds > max: 40, // max requests in the window > }, > advanced: { > cookiePrefix: "yummy", > // https://www.better-auth.com/docs/integrations/hono#cross-domain-cookies > crossSubDomainCookies: { > enabled: true, > }, > // https://www.better-auth.com/docs/concepts/database#id-generation > // generateId: o => o.model > }, > user: { > modelName: "users", > fields: { > name: "full_name", > emailVerified: "email_verified", > createdAt: "created_at", > image: "pfp_url", > updatedAt: "updated_at", > }, > }, > jwks: { > modelName: "jwks", > fields: { > privateKey: "private_key", > publicKey: "public_key", > createdAt: "created_at", > }, > }, > session: { > modelName: "auth_sessions", > fields: { > userId: "user_id", > ipAddress: "ip", > userAgent: "user_agent", > expiresAt: "expires_at", > createdAt: "created_at", > updatedAt: "updated_at", > }, > }, > account: { > modelName: "auth_providers", > fields: { > userId: "user_id", > createdAt: "created_at", > updatedAt: "updated_at", > accessTokenExpiresAt: "access_token_exp_at", > refreshTokenExpiresAt: "refresh_token_exp_at", > accessToken: "access_token", > refreshToken: "refresh_token", > providerId: "sso_provider_id", > accountId: "sso_account_id", > }, > }, > verification: { > modelName: "auth_verifications", > fields: { > expiresAt: "expires_at", > createdAt: "created_at", > updatedAt: "updated_at", > }, > }, > }); > Then in the routes: > > import { Hono } from "hono"; > import { cors } from "hono/cors"; > import { contextMiddleware } from "./mw/ctx"; > import type { AppEnv } from "./types/Env"; > import { contextStorage } from "hono/context-storage"; > import { getAuth } from "./auth"; > > const app = new Hono<AppEnv>(); > > app.use("*", contextMiddleware); > > app.use( > "/api/auth/**", > cors({ > origin: "http://localhost:8787", // replace with your origin > allowHeaders: ["Content-Type", "Authorization"], > allowMethods: ["POST", "GET", "OPTIONS"], > exposeHeaders: ["Content-Length"], > maxAge: 600, > credentials: true, > }), > ); > > app.on(["POST", "GET"], "/api/auth/**", (c) => getAuth(c).handler(c.req.raw)); > > // this is optional since you can get the session from better-auth endpoint > app.get("/session", async (c) => { > const session = c.get("session"); // ctx.ts to see how this is set > const user = c.get("user"); > > if (!user) return c.body(null, 401); > > return c.json({ > session, > user, > }); > }); > > export default app; > ctx.ts > > import { getAuth } from "@/auth"; > import type { AppEnv } from "@/types/Env"; > import { getConnection } from "@cpr/utils"; > import { createMiddleware } from "hono/factory"; > > export const contextMiddleware = createMiddleware<AppEnv>(async (c, next) => { > // `getConnection` is an internal pkg from my monorepo > // returns a postgres-js drizzle instance > const db = getConnection(c.env.DB_URL); > > c.set("db", db); // set db to access it in `getAuth` > > // you don't really need any of this last part > const session = await getAuth(c).api.getSession({ > headers: c.req.raw.headers, > }); > > if (!session) { > c.set("user", null); > c.set("session", null); > return next(); > } > > c.set("user", session.user); > c.set("session", session.session); > await next() > }); > Caveats: > > As [@Kylar13](https://github.com/Kylar13) pointed out, the CLI won’t work in this case because it relies on the auth singleton. If the maintainers are open to it, I’d be happy to submit a PR to make functions compatible with the CLI! :) > > That said, not using the CLI isn’t a huge issue—you can simply check the documentation for the plugin you need and manually create or update the schema. If you’re renaming columns, just be sure to map the correct fields in the `betterAuth` config. > > I’m still running into some issues with React Native, but they don’t seem related to this. I’ll need to do some more testing to confirm if there’s actually a problem. I spent a day studying this issue, I tried to modify the core of the cli to load a function, but I still got an error ```shell ERROR: `getCloudflareContext` has been called without having called `initOpenNextCloudflareForDev` from the Next.js config file. ``` This has nothing to do with better-auth, although I added `initOpenNextCloudflareForDev()` to the top of auth.ts, it still doesn't work, it seems that `initOpenNextCloudflareForDev` only supports starting with `next dev`.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#8615