Skip to content
Gabriel Carvalho
Go back

Auth at the Edge: Better Auth + Cloudflare Workers

Edit page

If you’re building an API on Cloudflare Workers and need authentication, Better Auth is probably the best choice today. It works like Lego: you declare the plugins you want (email/password, OAuth, 2FA, admin) and it generates all the routes, callbacks and even the database schema.

The combo with Cloudflare is particularly good because:

If you want to skip straight to the code, there’s a ready-made template: github.com/gabszs/workers-template

Table of contents

Open Table of contents

The problem we’re solving

Authentication is one of those problems that seems simple until you start implementing it. You think “oh, it’s just a login with email and password”, and suddenly you’re debugging OAuth flows, writing migrations for refresh tokens, and wondering why the cookie isn’t being set in Safari.

Better Auth exists because someone decided this suffering was optional. You configure a JavaScript object with what you want, and it handles the rest: routes, handlers, validations, database schema.

The interesting part is that it works through plugins. Want admin? Add admin(). Want phone login? Add phoneNumber(). Each plugin can add database fields and API routes automatically.


Stack

ComponentPurpose
HonoWeb framework. Fast, lightweight, runs anywhere
Better AuthAuth framework. Modular, type-safe
DrizzleORM. Generates SQL queries without black magic
D1Cloudflare’s SQLite. Serverless, at the edge
better-auth-cloudflareGlues everything together with CF bindings

Initial setup

First, the dependencies:

pnpm create hono  # select cloudflare-workers template
pnpm add better-auth better-auth-cloudflare  # auth framework + CF bindings
pnpm add drizzle-orm  # type-safe ORM
pnpm add -D drizzle-kit  # migration CLI
pnpm add resend  # email service

Environment variables

Create a .dev.vars at the root:

BETTER_AUTH_SECRET=a-long-random-string-here

# OAuth (optional)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=

# Email
RESEND_API_KEY=

# CORS
CORS_ORIGIN=http://localhost:3000,http://localhost:3001

The BETTER_AUTH_SECRET is used to sign tokens and cookies. Never commit this to the repo.

.dev.vars overrides wrangler.jsonc vars in local development. Use it for secrets you don’t want in version control.

wrangler.jsonc

This is where you declare your Cloudflare bindings (D1, KV, R2) and production environment variables:

{
  "name": "my-auth-api",
  "main": "src/index.ts",
  "compatibility_date": "2025-01-01",
  "compatibility_flags": ["nodejs_compat"],  // required for some dependencies
  "vars": {
    // production env vars (non-secrets)
    "CORS_ORIGIN": "https://myapp.com,https://www.myapp.com",
    "BETTER_AUTH_URL": "https://api.myapp.com/api/auth"
  },
  "kv_namespaces": [
    {
      "binding": "KV",  // accessible as env.KV in code
      "id": "your-kv-namespace-id",
      "preview_id": "your-kv-namespace-id"
    }
  ],
  "r2_buckets": [
    {
      "binding": "R2",  // accessible as env.R2 in code
      "bucket_name": "my-auth-uploads",
      "preview_bucket_name": "my-auth-uploads"
    }
  ],
  "d1_databases": [
    {
      "binding": "D1",  // accessible as env.D1 in code
      "database_id": "your-d1-database-id",
      "database_name": "my-auth-db",
      // IMPORTANT: tells wrangler where to find migration files
      "migrations_dir": "src/db/migrations",
      "preview_database_id": "your-d1-database-id"
    }
  ]
}

To create the bindings on Cloudflare:

wrangler d1 create my-auth-db        # creates D1, outputs database_id
wrangler kv namespace create KV       # creates KV, outputs namespace id
wrangler r2 bucket create my-auth-uploads  # creates R2 bucket

Cloudflare types

Run pnpm wrangler types to generate binding types. Your tsconfig.json needs to include:

{
  "compilerOptions": {
    "types": ["./worker-configuration.d.ts"]
  }
}

Drizzle config

Drizzle needs to know where the database is. In dev, Wrangler creates a .sqlite file inside .wrangler/. In prod, it uses D1 via HTTP.

// drizzle.config.ts
import fs from "node:fs";
import path from "node:path";
import { defineConfig } from "drizzle-kit";

// Finds the local .sqlite file created by wrangler
function getLocalD1DB() {
  try {
    const basePath = path.resolve(".wrangler");
    const dbFile = fs
      .readdirSync(basePath, { encoding: "utf-8", recursive: true })
      .find((f) => f.endsWith(".sqlite"));

    if (!dbFile) {
      throw new Error(`.sqlite file not found in ${basePath}`);
    }

    return path.resolve(basePath, dbFile);
  } catch (err) {
    console.log(`Error: ${err}`);
  }
}

export default defineConfig({
  dialect: "sqlite",
  schema: "./src/db/models.ts",
  out: "./src/db/migrations",
  // prod: connects via D1 HTTP API | dev: uses local .sqlite file
  ...(process.env.ALCHEMY_STAGE === "prod"
    ? {
        driver: "d1-http",
        dbCredentials: {
          accountId: process.env.CLOUDFLARE_D1_ACCOUNT_ID,
          databaseId: process.env.CLOUDFLARE_DATABASE_ID,
          token: process.env.CLOUDFLARE_D1_API_TOKEN,
        },
      }
    : {
        dbCredentials: {
          url: getLocalD1DB(),
        },
      }),
});

The minimal configuration

If you just want email/password working, the setup is straightforward:

// src/lib/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { drizzle } from "drizzle-orm/d1";
import { models } from "../db/models";

function createAuth(env: CloudflareBindings) {
  // Initialize Drizzle with D1 binding
  const db = drizzle(env.D1, { schema: models });

  return betterAuth({
    // Connect Better Auth to Drizzle/D1
    database: drizzleAdapter(db, {
      provider: "sqlite",
      usePlural: true,  // tables: users, sessions (not user, session)
      schema: models,
    }),
    emailAndPassword: { enabled: true },  // enables /sign-in, /sign-up routes
    trustedOrigins: env.CORS_ORIGIN?.split(",") || ["http://localhost:3000"],
    secret: env.BETTER_AUTH_SECRET,  // used to sign tokens/cookies
    basePath: "/api/auth",
  });
}

export function getAuthInstance(env: CloudflareBindings) {
  return createAuth(env);
}

export { createAuth };

Done. This already gives you /api/auth/sign-in, /api/auth/sign-up, /api/auth/sign-out, and session validation.


The complete configuration (with the interesting plugins)

This is where things get fun. The better-auth-cloudflare package adds native integrations with D1, KV and R2. Combined with the official plugins, you get a pretty robust auth system.

// src/lib/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin, emailOTP, openAPI, phoneNumber } from "better-auth/plugins";
import { withCloudflare } from "better-auth-cloudflare";
import { drizzle } from "drizzle-orm/d1";
import { Resend } from "resend";
import { models } from "../db/models";

function createAuth(env: CloudflareBindings, cf?: IncomingRequestCfProperties) {
  const db = drizzle(env.D1, { schema: models });

  return betterAuth({
    // withCloudflare spreads KV cache, R2 uploads, and geolocation config
    ...withCloudflare(
      {
        autoDetectIpAddress: true,  // extracts IP from CF-Connecting-IP header
        geolocationTracking: true,  // stores location data on each session
        cf: cf || {},  // CF request properties (contains geo data)
        d1: {
          db,
          options: {
            usePlural: true,
            debugLogs: true,
          },
        },
        kv: env.KV,  // session cache: reduces auth latency from 800ms to 20ms
        r2: {
          bucket: env.R2,  // per-user file uploads
          maxFileSize: 2 * 1024 * 1024,  // 2MB limit
          allowedTypes: [".jpg", ".jpeg", ".png", ".gif"],
        },
      },
      {
        emailAndPassword: {
          enabled: true,
          requireEmailVerification: true,
          // called when user requests password reset
          sendResetPassword: async ({ user, url }) => {
            const resend = new Resend(env.RESEND_API_KEY);
            await resend.emails.send({
              from: "App <noreply@yourdomain.com>",
              to: user.email,
              subject: "Reset your password",
              html: `<p>Click <a href="${url}">here</a> to reset your password.</p>`,
            });
          },
        },
        emailVerification: {
          // called on sign-up to verify email
          sendVerificationEmail: async ({ user, url }) => {
            const resend = new Resend(env.RESEND_API_KEY);
            await resend.emails.send({
              from: "App <noreply@yourdomain.com>",
              to: user.email,
              subject: "Verify your email",
              html: `<p>Click <a href="${url}">here</a> to verify your email.</p>`,
            });
          },
          sendOnSignUp: true,  // auto-send verification on registration
          autoSignInAfterVerification: true,
        },
        plugins: [
          openAPI(),  // generates /api/auth/reference docs
          admin(),  // user management: ban, roles, impersonation
          phoneNumber(),  // SMS/WhatsApp login
          emailOTP({
            // sends 6-digit code instead of magic link
            async sendVerificationOTP({ email, otp, type }) {
              if (type === "sign-in") {
                const resend = new Resend(env.RESEND_API_KEY);
                await resend.emails.send({
                  from: "App <noreply@yourdomain.com>",
                  to: email,
                  subject: "Your verification code",
                  html: `<p>Your code: <strong>${otp}</strong></p>`,
                });
              }
            },
          }),
        ],
        // OAuth providers - credentials from env vars
        socialProviders: {
          google: {
            clientId: env.GOOGLE_CLIENT_ID || "",
            clientSecret: env.GOOGLE_CLIENT_SECRET || "",
          },
          github: {
            clientId: env.GITHUB_CLIENT_ID || "",
            clientSecret: env.GITHUB_CLIENT_SECRET || "",
          },
        },
      }
    ),
    database: drizzleAdapter(db, {
      provider: "sqlite",
      usePlural: true,
      schema: models,
    }),
    trustedOrigins: env.CORS_ORIGIN?.split(",") || ["http://localhost:3000"],
    secret: env.BETTER_AUTH_SECRET,
    basePath: "/api/auth",
    telemetry: { enabled: false },
  });
}

export function getAuthInstance(env: CloudflareBindings) {
  return createAuth(env);
}

export { createAuth };

Plugins worth using

openAPI()

Generates OpenAPI documentation for all auth routes. Access /api/auth/reference and you get an interactive UI to test everything.

There’s a live example here: template-hono-workers-api.gabszs.workers.dev/api/auth/reference

admin()

Adds user management: listing, banning, session impersonation, role management. Added fields:

phoneNumber()

Adds users.phoneNumber and users.phoneNumberVerified at users table. Enables login via SMS/WhatsApp. phoneNumber endpoints Endpoints for sending and verifying codes via SMS/WhatsApp

emailOTP()

Alternative to magic link. User receives a 6-digit code by email instead of a link. emailOTP endpoints Endpoints for sending and verifying 6-digit codes via email

withCloudflare() - this is the important one

The better-auth-cloudflare package is what makes the Cloudflare integration worthwhile. withCloudflare endpoints Endpoints for KV, R2 and automatic geolocation integration

KV as session cache:

This is the most impactful feature. Verifying sessions on D1 during cold start takes ~800ms-1s. With KV, it drops to ~12-20ms. The difference is massive in applications with lots of authenticated requests. KV-Storage print Cloudflare KV dashboard showing cached user sessions

Cached get-session requests Response time comparison: ~12-20ms with KV cache vs ~800ms-1s without cache

R2 for uploads:

Creates automatic routes for per-user file uploads. You configure max file size and allowed types, and each file gets associated with the logged-in user.

Geolocation on sessions:

Automatically adds:

You can see where your users are logging in from without doing anything.


OAuth: Google and GitHub

Configuring OAuth is just passing the credentials:

socialProviders: {
  google: {
    clientId: env.GOOGLE_CLIENT_ID || "",
    clientSecret: env.GOOGLE_CLIENT_SECRET || "",
  },
  github: {
    clientId: env.GITHUB_CLIENT_ID || "",
    clientSecret: env.GITHUB_CLIENT_SECRET || "",
  },
},

To get the credentials:

  1. Google: console.cloud.google.com → APIs & Services → Credentials → OAuth 2.0 Client ID

    • Redirect URI: https://your-domain.com/api/auth/callback/google
  2. GitHub: github.com/settings/developers → OAuth Apps → New

    • Callback URL: https://your-domain.com/api/auth/callback/github

Better Auth generates the routes automatically:


The generated schema

The Better Auth CLI generates the Drizzle schema automatically. After running pnpm auth:generate, you’ll have something like this:

// src/db/authModels.ts
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";

export const users = sqliteTable("users", {
  id: text("id").primaryKey(),
  name: text("name").notNull(),
  email: text("email").notNull().unique(),
  emailVerified: integer("email_verified", { mode: "boolean" }).default(false).notNull(),
  image: text("image"),
  createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
  updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
  // admin()
  role: text("role"),
  banned: integer("banned", { mode: "boolean" }).default(false),
  banReason: text("ban_reason"),
  banExpires: integer("ban_expires", { mode: "timestamp_ms" }),
  // phoneNumber()
  phoneNumber: text("phone_number").unique(),
  phoneNumberVerified: integer("phone_number_verified", { mode: "boolean" }),
});

export const sessions = sqliteTable("sessions", {
  id: text("id").primaryKey(),
  expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
  token: text("token").notNull().unique(),
  ipAddress: text("ip_address"),
  userAgent: text("user_agent"),
  userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
  // withCloudflare geolocation
  timezone: text("timezone"),
  city: text("city"),
  country: text("country"),
  region: text("region"),
  regionCode: text("region_code"),
  colo: text("colo"),
  latitude: text("latitude"),
  longitude: text("longitude"),
  // admin()
  impersonatedBy: text("impersonated_by"),
});

Setting up the Hono handler

// src/index.ts
import { Hono } from "hono";
import { cors } from "hono/cors";
import { createAuth, getAuthInstance } from "./lib/auth";

type AppBindings = {
  Bindings: CloudflareBindings;
  Variables: {
    userId: string;
    auth: ReturnType<typeof createAuth>;
  };
};

const app = new Hono<AppBindings>();

// CORS with credentials for cookie-based auth
app.use(
  "/*",
  cors({
    origin: (origin, c) => c.env.CORS_ORIGIN?.split(",") || ["http://localhost:3000"],
    credentials: true,  // required for cookies to work cross-origin
  })
);

// Inject auth instance with CF request metadata (for geolocation)
app.use("*", async (c, next) => {
  if (c.req.path.startsWith("/api/auth")) {
    const auth = createAuth(c.env, (c.req.raw as any).cf || {});
    c.set("auth", auth);
  }
  await next();
});

// Let Better Auth handle all /api/auth/* routes
app.all("/api/auth/*", async (c) => {
  const auth = c.get("auth");
  return auth.handler(c.req.raw);
});

export default app;

Protecting routes

// src/lib/middleware.ts
import { createMiddleware } from "hono/factory";
import { getAuthInstance } from "./auth";

export const authMiddleware = createMiddleware(async (c, next) => {
  const auth = getAuthInstance(c.env);
  // Validates session from cookies/headers (uses KV cache if configured)
  const session = await auth.api.getSession({ headers: c.req.raw.headers });

  if (!session?.user) {
    return c.text("Unauthorized", 401);
  }

  c.set("userId", session.user.id);  // makes userId available in handlers
  await next();
});

Usage:

import { Hono } from "hono";
import { authMiddleware } from "./lib/middleware";

const app = new Hono();

app.get("/health", (c) => c.json({ status: "ok" }));  // public

app.use("/v1/*", authMiddleware);  // protect all /v1/* routes

app.get("/v1/me", (c) => {
  const userId = c.get("userId");  // set by authMiddleware
  return c.json({ userId });
});

Generating the frontend with AI

The openAPI() plugin generates a complete specification at /api/auth/openapi.json. You can feed this to an AI and ask it to generate frontend hooks:

Generate authentication hooks for React based on this OpenAPI spec:

[paste the JSON here]

Requirements:
- Native fetch with credentials: 'include'
- TypeScript
- Hooks for: sign-in, sign-up, sign-out, get-session
- Error handling

It works surprisingly well.


Useful scripts

{
  "scripts": {
    "dev": "wrangler types && wrangler dev",
    "deploy": "wrangler types && wrangler deploy",
    "cf-typegen": "wrangler types",
    "auth:generate": "ALCHEMY_STAGE=dev npx @better-auth/cli@latest generate --config src/lib/auth.ts --output src/db/authModels.ts -y",
    "db:generate": "drizzle-kit generate",
    "db:migrate:dev": "wrangler d1 migrations apply your-db --local",
    "db:migrate:prod": "wrangler d1 migrations apply your-db --remote",
    "studio": "drizzle-kit studio"
  }
}

Development workflow

Initial setup

pnpm cf-typegen      # 1. generate CF bindings types
pnpm auth:generate   # 2. generate Drizzle schema from plugins
pnpm db:generate     # 3. create SQL migration files
pnpm db:migrate:dev  # 4. apply migrations to local D1
pnpm dev             # 5. start dev server

Adding a plugin

# 1. Add the plugin in src/lib/auth.ts

# 2. Regenerate the schema
pnpm auth:generate

# 3. Generate new migration
pnpm db:generate

# 4. Apply locally
pnpm db:migrate:dev

# 5. In prod
pnpm db:migrate:prod

The auth:generate command needs to run every time you modify plugins. It reads your config and generates the corresponding models.


Deploy

pnpm db:migrate:prod  # migrations first
pnpm deploy           # then the worker

Why this stack?



If you liked this post, have any feedback or questions, you can reach me on WhatsApp or email.

By Gabriel Carvalho


Edit page
Share this post on:

Previous Post
Open-Telemetry part 1: Instrumenting Python (With profiles + LGTM)
Next Post
deploy series 01: deploy your containers with capRover.