Better Auth + Cloudflare Workers: The Integration Guide Nobody Wrote
How I fixed 33-second hangs, mysterious 503s, and session dropouts by rethinking the auth instance lifecycle.
Better Auth + Cloudflare Workers: The Integration Guide Nobody Wrote

How I fixed 33-second hangs, mysterious 503s, and session dropouts by rethinking the auth instance lifecycle.
I migrated my API to Better Auth running on Cloudflare Workers with D1 (SQLite) and KV. The setup looked clean on paper: Drizzle adapter for D1, KV as secondary storage for session caching, magic-link and email-OTP sign-in flows.
Then the logs started looking like this:
[wrangler:info] GET /api/auth/magic-link/verify 302 FOUND (33351ms)
✘ [ERROR] Uncaught Error: Network connection lost.
[wrangler:info] POST /api/auth/sign-in/email-otp 503 Service Unavailable (153ms)
33-second hangs. Uncaught errors from background tasks. 503 responses while the application itself was logging 200. This post documents what was going wrong and how we fixed it.
The Setup
My stack:
- Hono on Cloudflare Workers
- Better Auth for authentication
- D1 (Cloudflare’s SQLite) as the primary database via Drizzle ORM
- KV as secondary storage for session caching
- Auth methods: magic link, email OTP, passkeys, social OAuth, API keys
The wrangler configuration was standard:
[[d1_databases]]
binding = "DB"
database_name = "my-db"
database_id = "..."
[[kv_namespaces]]
binding = "AUTH_KV"
id = "..."
Bug 1: The Dual Auth Instance Problem
What I built
My initial architecture followed what seemed like a reasonable separation of concerns: a singleton auth instance for reading sessions in middleware (no waitUntil needed for reads) and a per-request auth instance for the auth handler routes (with waitUntil to keep the Worker alive during background tasks):
// auth.ts — the original approach
let _instance: ReturnType<typeof createAuth> | null = null;
// Singleton for middleware — "reads don't need background tasks"
export function getAuthInstance(env: Env) {
if (!_instance) {
_instance = createAuth(buildAuthEnv(env), createD1DB(env.DB), buildEmailProvider(env));
}
return _instance;
}
// Per-request for /api/auth/* — "auth routes need waitUntil"
export function createRequestAuth(env: Env, ctx: ExecutionContext) {
return createAuth(
buildAuthEnv(env),
createD1DB(env.DB),
buildEmailProvider(env),
(p) => ctx.waitUntil(p),
);
}
And in cloudflare.ts:
// Middleware uses the singleton
app.use("*", requireAuth); // internally calls getAuthInstance(env)
// Auth routes get a fresh instance
app.all("/api/auth/*", async (c) => {
const auth = createRequestAuth(c.env as Env, c.executionCtx);
return auth.handler(c.req.raw);
});
Why it breaks
The core problem: both instances call createD1DB(env.DB) independently, creating two Drizzle wrappers around the same D1 binding.
In production this is mostly fine — D1 runs over HTTP and handles concurrency natively. But in local development (wrangler dev), D1 is backed by a local SQLite file via Miniflare. SQLite has a write-ahead log (WAL) lock: only one writer at a time.
When a magic-link verification fires, the auth handler instance writes a session record to D1. At the same time, the middleware singleton may be executing its own D1 queries for other in-flight requests. The second writer blocks waiting for the WAL lock — for up to 30+ seconds.
The 33-second hang is SQLite waiting for the other D1 instance to release its write lock.
The ✘ [ERROR] Uncaught Error: Network connection lost. that follows is a D1 transient error surfacing from a waitUntil background task (token cleanup, session write) that fires after the response is already sent. With two instances in play, the background task from the auth handler conflicts with the singleton’s ongoing queries.
The fix: one auth instance per request
The pattern used by the better-auth-cloudflare library is instructive — create the auth instance once per request in a Hono middleware and store it on the context:
// cloudflare.ts
app.use("*", async (c, next) => {
const auth = createRequestAuth(c.env as Env, c.executionCtx);
c.set("auth", auth);
await next();
});
// Auth routes reuse the same instance from context
app.all("/api/auth/*", async (c) => {
const auth = c.get("auth");
return auth.handler(c.req.raw);
});
One Drizzle instance per request. One D1 connection. No write lock contention, even under local SQLite.
The auth middleware factory signature changes to receive auth from context instead of a cached getter:
// Before
export function createAuthMiddleware(getAuth: () => Auth)
// After
export function createAuthMiddleware(getAuth: (c: Context) => Auth)
And the middleware is created once, reading auth from context on every call:
export function getAuthMiddleware() {
return createAuthMiddleware((c: Context) => c.get("auth") as Auth);
}
Add auth to the Hono ContextVariableMap for full type safety:
// types.ts
import type { Auth } from "@repo/shared";
declare module "hono" {
interface ContextVariableMap {
auth: Auth;
// ... other context vars
}
}
Bug 2: The 503 vs 200 Mismatch
The symptom
Application logs showed status: 200, durationMs: 142 for POST /api/auth/sign-in/email-otp. Wrangler showed 503 Service Unavailable for the same request. Two different status codes for the same request.
{"path":"/api/auth/sign-in/email-otp","status":200,"durationMs":142}
[wrangler:info] POST /api/auth/sign-in/email-otp 503 Service Unavailable (153ms)
Why it happens
After a 33-second SQLite hang from the previous magic-link request, the local D1 instance is left in a degraded state. The next D1 write attempt returns a 503 HTTP error from the D1 infrastructure itself — before Better Auth’s own response body is written.
The application middleware captures 200 from the auth handler’s Response object, but D1’s 503 error surfaces at the Worker boundary before the response reaches the client. Wrangler reports the final status from the infrastructure layer.
Fix: Eliminating Bug 1 eliminates Bug 2. One auth instance → no write lock contention → D1 never gets stuck → no cascading 503s.
Bug 3: Cloudflare KV TTL and Rate Limiting
The symptom
Better Auth’s rate limiter uses secondaryStorage to persist rate-limit counters. When the storage backend is Cloudflare KV, some endpoints pass a TTL of 10 seconds internally — below KV’s minimum TTL of 60 seconds. This causes KV writes to fail silently (or loudly, depending on how you handle errors).
This is a known open issue: better-auth/better-auth#7124 and #5452.
The fix: separate rate limit storage
Separate the rate-limit storage from session storage using rateLimit.customStorage with an explicit 60s minimum:
rateLimit: {
window: 60,
max: 30,
customStorage: {
get: async (key: string) => {
try {
const data = await env.AUTH_KV.get(key);
return data ? JSON.parse(data) : undefined;
} catch {
return undefined;
}
},
set: async (key: string, value: unknown) => {
try {
// Always use 60s — KV minimum, matches the rate limit window
await env.AUTH_KV.put(key, JSON.stringify(value), { expirationTtl: 60 });
} catch (e) {
console.warn("[auth] Rate limit KV set failed", { key, error: String(e) });
}
},
delete: async (key: string) => {
try {
await env.AUTH_KV.delete(key);
} catch (e) {
console.warn("[auth] Rate limit KV delete failed", { key, error: String(e) });
}
},
},
}
Meanwhile, the secondaryStorage for sessions keeps the existing pattern with its own TTL clamping:
secondaryStorage: {
get: async (key) => {
try {
return await env.AUTH_KV.get(key);
} catch {
return null;
}
},
set: async (key, value, ttl) => {
try {
const effectiveTtl = ttl ? Math.max(ttl, 60) : undefined;
await env.AUTH_KV.put(key, value, effectiveTtl ? { expirationTtl: effectiveTtl } : undefined);
} catch (e) {
console.warn("[auth] KV set failed", { key, error: String(e) });
}
},
delete: async (key) => {
try {
await env.AUTH_KV.delete(key);
} catch (e) {
console.warn("[auth] KV delete failed", { key, error: String(e) });
}
},
}
Bug 4: cookieCache + secondaryStorage Forces Re-Login
The symptom
Users are logged out after exactly 5 minutes, regardless of session lifetime. The session is valid in D1 and KV, but Better Auth doesn’t refresh it.
Why it happens
This is an open bug in Better Auth: #4203 (reopened Jan 2026). When cookieCache is enabled alongside secondaryStorage, Better Auth fails to properly fall back to secondary storage after the cookie cache expires, treating the expired cache as a logout event instead of refreshing from storage.
The workaround
Disable cookieCache until the upstream bug is resolved:
session: {
storeSessionInDatabase: true,
// cookieCache disabled — better-auth bug #4203:
// sessions are not refreshed from secondaryStorage after maxAge expires.
// Re-enable once the upstream bug is fixed.
updateAge: 60 * 15,
}
This trades some performance (an extra D1 read per session check) for correctness (users stay logged in).
The Final Architecture
Incoming Request
│
▼
traceMiddleware / metricsMiddleware / loggingMiddleware
│
▼
Auth Middleware ←── createRequestAuth(env, ctx) once per request
c.set("auth", auth) (one D1 instance, one KV connection)
│
▼
CORS Middleware
│
├──► /api/auth/* → c.get("auth").handler(req)
│
└──► /v1/* → requireAuth → c.get("auth").api.getSession()
│
▼
Route Handlers
The critical invariant: one Drizzle D1 instance per request, created at the top of the middleware chain and shared by everyone downstream.
The Complete Better Auth Config for Cloudflare
export function createAuth(
env: AuthEnv,
db: DrizzleD1Database<any>,
emailProvider: EmailProvider,
waitUntil?: (p: Promise<unknown>) => void,
) {
return betterAuth({
baseURL: env.BETTER_AUTH_URL,
secret: env.BETTER_AUTH_SECRET,
database: drizzleAdapter(db, {
provider: "sqlite",
schema: { /* your schema tables */ },
}),
session: {
storeSessionInDatabase: true,
// cookieCache disabled — bug #4203 (re-enable when upstream fixes it)
updateAge: 60 * 15,
},
secondaryStorage: {
get: async (key) => {
try { return await env.AUTH_KV.get(key); }
catch { return null; }
},
set: async (key, value, ttl) => {
try {
const effectiveTtl = ttl ? Math.max(ttl, 60) : undefined;
await env.AUTH_KV.put(key, value, effectiveTtl ? { expirationTtl: effectiveTtl } : undefined);
} catch (e) {
console.warn("[auth] KV set failed", { key, error: String(e) });
}
},
delete: async (key) => {
try { await env.AUTH_KV.delete(key); }
catch (e) { console.warn("[auth] KV delete failed", { key, error: String(e) }); }
},
},
rateLimit: {
window: 60,
max: 30,
customStorage: {
get: async (key) => {
try {
const data = await env.AUTH_KV.get(key);
return data ? JSON.parse(data) : undefined;
} catch { return undefined; }
},
set: async (key, value) => {
try {
await env.AUTH_KV.put(key, JSON.stringify(value), { expirationTtl: 60 });
} catch (e) { console.warn("[auth] Rate limit KV set failed", { key, error: String(e) }); }
},
delete: async (key) => {
try { await env.AUTH_KV.delete(key); }
catch (e) { console.warn("[auth] Rate limit KV delete failed", { key, error: String(e) }); }
},
},
},
advanced: {
crossSubDomainCookies: isLocal
? { enabled: false }
: { enabled: true, domain: getRootDomain(env) },
defaultCookieAttributes: {
secure: !isLocal,
sameSite: "lax",
},
...(waitUntil ? {
backgroundTasks: {
handler: (p: Promise<unknown>) =>
waitUntil(
p.catch((err) => console.warn("[auth] Background task failed:", String(err)))
),
},
} : {}),
},
// ... your plugins (magicLink, emailOTP, passkey, organization, etc.)
});
}
Key Takeaways
1. One auth instance per request, always.
Don’t split into a “singleton for reads” and “per-request for writes”. The D1 binding is the same object — two Drizzle wrappers around it fight over SQLite’s write lock in local dev.
2. Store the auth instance on Hono context.
c.set(“auth”, auth) at the top of your middleware chain. Read it anywhere with c.get(“auth”). No module-level singletons.
3. Always pass “ctx.waitUntil” to the auth instance.
Better Auth runs background tasks (token cleanup, session writes) after the response is sent. Without waitUntil, the Worker exits before they complete, causing “Network connection lost” errors.
4. Cloudflare KV has a 60-second minimum TTL.
Use Math.max(ttl, 60) in your secondaryStorage.set. Use rateLimit.customStorage with hardcoded 60s to separate rate limit data from session data.
5. “cookieCache” + “secondaryStorage” is currently broken.
Disable cookieCache until better-auth#4203 is resolved. The extra D1 read per request is worth the correctness.
6. The 33-second hang in local dev is a SQLite WAL lock, not a network issue.
It won’t reproduce in production against real D1. But fixing it (by using one auth instance) also eliminates the root cause of 503s in production.
References
- better-auth-cloudflare — the most complete Cloudflare integration for Better Auth
- Better Auth docs: Secondary Storage
- Better Auth docs: Cloudflare D1 adapter
- Cloudflare D1: retry queries
- Issue #4203: cookieCache + secondaryStorage forces re-login
- Issue #7124: KV TTL mismatch with rate limiting
- Issue #5452: rate limit TTL hardcoded at 10s
메타데이터
- post_id
- 8480331d805f
- slug
- better-auth-cloudflare-workers-the-integration-guide-nobody-wrote-8480331d805f
- url
- https://medium.com/@senioro.valentino/better-auth-cloudflare-workers-the-integration-guide-nobody-wrote-8480331d805f
- canonical_url
- https://medium.com/@senioro.valentino/better-auth-cloudflare-workers-the-integration-guide-nobody-wrote-8480331d805f
- author_url
- https://medium.com/@senioro.valentino
- status
- ok
- fetched_at
- 2026-06-09 15:37:30