Better Auth Developer Guide
Better Auth Developer Guide
Plugin catalogue, adapter swapping, custom provider creation, and social provider integration for the bloqr-backend authentication system.
Plugin Catalogue
Better Auth is plugin-based. Each plugin adds database tables, routes, and/or token
processing. Plugins are registered in worker/lib/auth.ts. For the full per-plugin
rationale (why a plugin is gated the way it is, what it’s mutually exclusive with,
etc.) see the module-level JSDoc at the top of worker/lib/auth.ts and the
isXEnabled()/buildXOptions() helper JSDoc for each conditional plugin — this
table is a summary, that file is the source of truth. See also the
Plugin Extension Pack guide for a task-oriented
walkthrough (enabling each conditional plugin, endpoints exposed, gotchas).
Always-On Plugins
Wired unconditionally — no environment configuration required.
| Plugin | Package | Route Prefix | What It Adds |
|---|---|---|---|
dash() | @better-auth/infra | — | Better Auth Dash dashboard integration; requires BETTER_AUTH_API_KEY passed explicitly via apiKey: env.BETTER_AUTH_API_KEY — Cloudflare Workers do not expose Worker Secrets via process.env; no-ops without the key |
bearer() | better-auth/plugins | — | Authorization: Bearer <token> header auth (session tokens + API keys) |
twoFactor({ issuer }) | better-auth/plugins | /api/auth/two-factor/* | TOTP-based 2FA; twoFactor DB table |
multiSession() | better-auth/plugins | /api/auth/list-sessions, /revoke-session, /revoke-other-sessions | Concurrent sessions per user |
admin() | better-auth/plugins | /api/auth/admin/* | User management endpoints; banned, role fields on user |
organization() | better-auth/plugins | /api/auth/organization/* | Multi-tenant organizations, members, roles |
haveIBeenPwned() | better-auth/plugins | — | Rejects sign-up/reset passwords found in known breaches (k-anonymity API check, no config) |
lastLoginMethod({ storeInDatabase: true }) | better-auth/plugins | — | Tracks last-used auth method; adds User.lastLoginMethod |
oneTimeToken() | better-auth/plugins | /api/auth/one-time-token/* | Single-use session handoff tokens (e.g. CLI-to-browser flows) |
jwt(...) | better-auth/plugins | /api/auth/token, /api/auth/jwks | Short-lived JWTs + JWKS endpoint for services that can’t consume Better Auth sessions directly; jwks DB table |
deviceAuthorization({ verificationUri: '/device' }) | better-auth/plugins | /api/auth/device/* | RFC 8628 device flow (CLI/TV/IoT sign-in); deviceCode DB table |
Conditionally-Loaded Plugins (env-gated)
Off by default — each requires an explicit BETTER_AUTH_*_ENABLED=true flag (or,
for captcha(), presence of its secret). See
Configuration Guide → Plugin Extension Pack
for the full env var reference.
| Plugin | Package | Gate | What It Adds |
|---|---|---|---|
sentinel({ apiKey, kvUrl, security }) | @better-auth/infra | BETTER_AUTH_SENTINEL_ENABLED=true (Better Auth Pro tier only) | Credential stuffing protection, impossible travel detection, bot/IP blocking, device notifications |
captcha({ provider: 'cloudflare-turnstile', secretKey }) | better-auth/plugins | TURNSTILE_SECRET_KEY present | Cloudflare Turnstile bot protection on /sign-up/email, /sign-in/email, /request-password-reset |
apiKey(...) | @better-auth/api-key | BETTER_AUTH_APIKEY_ENABLED=true | The official Better Auth API key system — a deliberately separate system from this project’s custom blq_-prefixed ApiKey implementation; own x-api-key header, own BetterAuthApiKey table (see Plugin Extension Pack for why) |
mcp({ loginPage }) | better-auth/plugins | BETTER_AUTH_MCP_ENABLED=true | OAuth provider for MCP clients; mutually exclusive with oidcProvider() (enforced at startup) |
oidcProvider({ loginPage, allowDynamicClientRegistration, useJWTPlugin }) | better-auth/plugins | BETTER_AUTH_OIDC_ENABLED=true | Makes bloqr an OIDC provider for third-party/enterprise clients; mutually exclusive with mcp() |
sso() | @better-auth/sso | BETTER_AUTH_SSO_ENABLED=true | Enterprise customer SAML/OIDC login; ssoProvider table |
scim({ providerOwnership: { enabled: true } }) | @better-auth/scim | BETTER_AUTH_SCIM_ENABLED=true | Inbound SCIM user provisioning from an enterprise identity provider; scimProvider table |
passkey({ rpID, rpName }) | @better-auth/passkey | BETTER_AUTH_PASSKEY_ENABLED=true + BETTER_AUTH_PASSKEY_RP_ID | WebAuthn; passkey table |
Auto-Enabled Plugins (email-gated)
Mounted automatically whenever a viable transactional email provider is configured
(RESEND_API_KEY or SEND_EMAIL — the same check used for requireEmailVerification).
No separate flag.
| Plugin | Package | Route Prefix | What It Adds |
|---|---|---|---|
magicLink(...) | better-auth/plugins | /api/auth/sign-in/magic-link, /api/auth/magic-link/verify | Passwordless sign-in via emailed link |
emailOTP(...) | better-auth/plugins | /api/auth/email-otp/* | Passwordless/verification/reset flows via emailed one-time code |
Installed But Never Mounted
| Plugin | Package | Why |
|---|---|---|
stripe(...) | @better-auth/stripe | Installed as a dependency and a buildStripeOptions() builder exists, but stripe() is never added to the plugins array. This repo already has a mature, hand-rolled Stripe billing system (webhook Durable Object, PAYG, SubscriptionPlan); mounting the plugin’s own customer/subscription/webhook handling alongside it risks duplicate Stripe customers and double-processed webhooks. See the buildStripeOptions() JSDoc in auth.ts for the full rationale. |
Pending Plugins
These plugins are not currently wired at runtime. Enable them once the prerequisite is met.
| Plugin | Package | Prerequisite | What It Adds |
|---|---|---|---|
auditLogs({ retention }) | @better-auth/infra | @better-auth/infra must publish this export (still absent as of 0.3.6). Track: github.com/better-auth/better-auth/issues?q=auditLogs+infra. In the meantime dash() already exposes getAuditLogs/getAllAuditLogs endpoints. | Records all auth events to DB; visual audit trail in Dash; 90-day retention |
How to Add a Plugin
Step 1 — Add the plugin import to worker/lib/auth.ts:
import { betterAuth } from 'better-auth';import { bearer, twoFactor, multiSession, admin, apiKey } from 'better-auth/plugins';Step 2 — Register it in the plugins array:
export const createAuth = (env: Env, baseURL?: string) => { return betterAuth({ // ... plugins: [ bearer(), twoFactor({ issuer: 'bloqr-backend' }), multiSession(), admin(), apiKey(), // ← new plugin ], });};Step 3 — Generate the Prisma migration (plugins that add tables):
# After adding the plugin, regenerate the Prisma schema:deno task db:generate# Check if new tables were added to schema.prisma, then:deno task db:migrateStep 4 — Run the tests:
deno task test:workerNot Recommended for This Project
Evaluated and deliberately skipped — no clear fit for a compiler/API SaaS today. Revisit if the product need materialises (see docs/architecture/better-auth-plugins-analysis.mdx for the original cost/benefit writeup).
| Plugin | Package | Why Skipped |
|---|---|---|
username() | better-auth/plugins | Email auth is sufficient; no username-based UX planned |
phoneNumber() | better-auth/plugins | No SMS/phone sign-in requirement |
anonymous() | better-auth/plugins | No anonymous-session-then-claim UX planned |
siwe() | better-auth/plugins | No wallet-based (Ethereum) auth requirement |
oauthProxy() | better-auth/plugins | No proxy-OAuth deployment topology in use |
oneTap() | better-auth/plugins | Google One Tap not part of the current sign-in UX |
i18n() | better-auth/plugins | No multi-locale UI currently planned |
User Model Extension
The user block in worker/lib/auth.ts configures two things:
fields— maps Better Auth’s canonical field names to the Prisma model’s field names.additionalFields— declares custom columns that extend the built-in user schema.
Field Name Mapping (fields)
Better Auth’s internal user object uses name (display name) and image (avatar URL).
The Prisma User model uses displayName (column display_name) and imageUrl
(column image_url) to follow the project’s database naming conventions.
Without this mapping, Better Auth passes name and image directly to Prisma on every
sign-up and OAuth profile-sync, causing PrismaClientValidationError: Unknown argument 'name'
and a 500 to the client.
The mapping is declared in worker/lib/auth.ts and exported as USER_FIELD_MAPPING so
regression tests can assert it without requiring a database connection:
export const USER_FIELD_MAPPING = { name: 'displayName', // Better Auth 'name' → Prisma 'displayName' (display_name column) image: 'imageUrl', // Better Auth 'image' → Prisma 'imageUrl' (image_url column)} as const;
// Inside createAuth():user: { fields: USER_FIELD_MAPPING, // ← required; prevents PrismaClientValidationError on sign-up additionalFields: { tier: { ... }, role: { ... }, },},⚠️ Regression risk: If
fieldsis removed or the Prisma model fields are renamed, every sign-up and OAuth sign-in will return a 500. The exportedUSER_FIELD_MAPPINGconstant and its associated regression test guard against this.
Additional Fields (additionalFields)
Two custom fields are added to the user table:
user: { fields: USER_FIELD_MAPPING, additionalFields: { tier: { type: 'string', required: false, defaultValue: 'free', input: false, // ← users cannot self-assign their tier }, role: { type: 'string', required: false, defaultValue: 'user', input: false, // ← users cannot self-assign admin role }, },},input: false means these fields are excluded from sign-up/sign-in request parsing — they
can only be written by server-side code (migrations, admin endpoints, Prisma Studio).
Adding a New Field to the User Model
- Edit
worker/lib/auth.ts, add the field touser.additionalFields:
additionalFields: { tier: { type: 'string', required: false, defaultValue: 'free', input: false }, role: { type: 'string', required: false, defaultValue: 'user', input: false }, // New field: companyName: { type: 'string', required: false, defaultValue: null, input: true },},- Add the column to
prisma/schema.prisma:
model User { id String @id @default(uuid()) @db.Uuid displayName String? @map("display_name") // ← mapped via USER_FIELD_MAPPING email String? @unique tier String @default("free") role String @default("user") companyName String? // ← new column // ...}Note: Do not use
nameas a column name in theusermodel — it conflicts with Better Auth’s internalnamefield. UsedisplayName(mapped viaUSER_FIELD_MAPPING) instead.
- Run the migration:
deno task db:migrate # creates and applies migrationdeno task db:generate # regenerates Prisma client- Update
worker/types.tsif you reference the field from middleware:
export interface IAuthProviderResult { valid: boolean; userId?: string; tier?: UserTier; role?: string; companyName?: string; // ← new field // ...}Database Adapter Swapping
Better Auth’s adapter layer is swappable. The production adapter is prismaAdapter.
Current Adapter — Neon PostgreSQL via Hyperdrive
import { prismaAdapter } from 'better-auth/adapters/prisma';import { createPrismaClientForWorker } from '../lib/prisma.ts';
export const createAuth = (env: Env, baseURL?: string) => { const prisma = createPrismaClientForWorker(env); return betterAuth({ database: prismaAdapter(prisma, { provider: 'postgresql' }), // ... });};A new PrismaClient is created per request (cheap due to Hyperdrive’s local connection pool).
Adapter Comparison
| Adapter | Package | When to Use |
|---|---|---|
prismaAdapter (current) | better-auth/adapters/prisma | Neon/Supabase PostgreSQL via Cloudflare Hyperdrive |
d1Adapter | better-auth/adapters/d1 | Cloudflare D1 (SQLite) — simpler, no Hyperdrive |
drizzleAdapter | better-auth/adapters/drizzle | Drizzle ORM with D1, Turso, or LibSQL |
kysely | better-auth/adapters/kysely | Raw Kysely query builder |
Switching to D1 (Example)
// worker/lib/auth.ts (hypothetical D1 migration)import { d1Adapter } from 'better-auth/adapters/d1';
export const createAuth = (env: Env) => { return betterAuth({ database: d1Adapter(env.DB), // env.DB = D1Database binding // ... });};Add to wrangler.toml:
[[d1_databases]]binding = "DB"database_name = "adblock-auth"database_id = "<your-d1-id>"Note: The project currently uses Neon PostgreSQL via Hyperdrive and Prisma. Switching to D1 requires migrating all existing user/session data.
Creating a Custom IAuthProvider
The IAuthProvider interface (in worker/types.ts) is the contract between the auth
middleware and any provider implementation.
Interface Contract
export interface IAuthProvider { /** * Verify the token or session from the request. * - Returns { valid: false } (not an error) when no credentials are present. * - Returns { valid: false, error } when credentials are present but invalid. * - Returns { valid: true, userId, tier, role, authMethod, providerUserId } on success. */ verifyToken(request: Request): Promise<IAuthProviderResult>;}
export interface IAuthProviderResult { valid: boolean; userId?: string; // internal DB id providerUserId?: string; // id in the external provider (e.g., auth0|abc123) tier?: UserTier; role?: string; authMethod?: IAuthContext['authMethod']; error?: string;}Example — Auth0 Custom Provider
import type { IAuthProvider, IAuthProviderResult, Env } from '../types.ts';import * as jose from 'jose';
export class Auth0Provider implements IAuthProvider { private readonly jwksUri: string; private readonly audience: string;
constructor(private readonly env: Env) { // Auth0 JWKS endpoint this.jwksUri = `https://${env.AUTH0_DOMAIN}/.well-known/jwks.json`; this.audience = env.AUTH0_AUDIENCE; }
async verifyToken(request: Request): Promise<IAuthProviderResult> { const authHeader = request.headers.get('Authorization'); if (!authHeader?.startsWith('Bearer ')) { return { valid: false }; // no credentials — not an error }
const token = authHeader.slice(7); try { const JWKS = jose.createRemoteJWKSet(new URL(this.jwksUri)); const { payload } = await jose.jwtVerify(token, JWKS, { audience: this.audience, issuer: `https://${this.env.AUTH0_DOMAIN}/`, });
return { valid: true, providerUserId: payload.sub, tier: 'free', // map from token claims or DB lookup role: 'user', authMethod: 'better-auth', // reuse the discriminated union }; } catch (err) { return { valid: false, error: (err as Error).message }; } }}Register in worker/middleware/auth.ts:
function buildProvider(env: Env): IAuthProvider { // Switch by environment variable to choose the provider at startup: if (env.USE_AUTH0 === 'true') { return new Auth0Provider(env); } return new BetterAuthProvider(env);}Authentication Request Flow
The three-tier chain in worker/middleware/auth.ts:
flowchart TD
A[Incoming Request] --> B{Authorization header\nstarts with abc_?}
B -- Yes --> C[authenticateApiKey\nhash + DB lookup]
C --> D{Key valid?}
D -- Yes --> E[IAuthContext authMethod=api-key]
D -- No --> F[401 Unauthorized]
B -- No --> G{Better Auth\nsession token present?}
G -- Yes --> H[BetterAuthProvider.verifyToken\nsession DB lookup]
H --> I{Session valid?}
I -- Yes --> J[IAuthContext authMethod=better-auth]
I -- No --> K[401 Unauthorized]
G -- No --> L[IAuthContext authMethod=anonymous\ntier=anonymous]
Guard Functions
import { requireAuth, requireTier, requireScope } from './middleware/auth.ts';
// Require any valid session (Better Auth or API key — not anonymous)app.use('/api/compile', requireAuth());
// Require at minimum the "pro" tierapp.use('/api/export/bulk', requireTier(UserTier.PRO));
// Require a specific scopeapp.use('/api/compile', requireScope(AuthScope.COMPILE));Adding a Social Provider
Step 1 — Register the OAuth App
For GitHub:
- Go to GitHub → Settings → Developer Settings → OAuth Apps → New OAuth App
- Set Authorization callback URL:
https://your-worker.workers.dev/api/auth/callback/github - Copy the Client ID and generate a Client Secret
Step 2 — Add Wrangler Secrets
wrangler secret put GITHUB_CLIENT_IDwrangler secret put GITHUB_CLIENT_SECRETStep 3 — Enable in worker/lib/auth.ts
GitHub is already conditionally enabled:
socialProviders: { ...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET ? { github: { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET, }, } : {}),},To add Google, uncomment the existing block:
socialProviders: { ...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET ? { github: { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET } } : {}), // Uncomment and set GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET to enable Google: ...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET ? { google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET, }, } : {}),},wrangler secret put GOOGLE_CLIENT_IDwrangler secret put GOOGLE_CLIENT_SECRETStep 4 — Deploy and Verify
wrangler deploycurl https://your-worker.workers.dev/api/auth/providers# { "emailPassword": true, "github": true, "google": true, "mfa": true }Writing Tests
Auth test files are in worker/. Use Deno’s built-in test runner.
Test File Locations
| File | What It Tests |
|---|---|
worker/lib/auth.test.ts | createAuth() factory — missing secrets, config validation |
worker/middleware/auth.test.ts | authenticateRequestUnified(), requireAuth(), requireTier(), requireScope() |
worker/middleware/better-auth-provider.test.ts | BetterAuthProvider.verifyToken() — valid sessions, expired sessions, missing headers |
worker/middleware/auth-extensibility.test.ts | IAuthProvider contract compliance |
worker/middleware/zta-auth-gates.test.ts | ZTA tier/scope enforcement — no trust from JWT claims |
worker/handlers/auth-config.test.ts | GET /api/auth/providers response |
worker/handlers/auth-admin.test.ts | Admin endpoints — list users, ban, revoke |
Running Auth Tests
# All testsdeno task test:worker
# A specific filedeno test --allow-read --allow-write --allow-net --allow-env worker/middleware/auth.test.ts
# With coveragedeno task test:coverageTest Pattern — BetterAuthProvider
// worker/middleware/better-auth-provider.test.ts (example shape)import { assertEquals } from '@std/assert';import { BetterAuthProvider } from './better-auth-provider.ts';
Deno.test('BetterAuthProvider returns valid=false with no Authorization header', async () => { const provider = new BetterAuthProvider(mockEnv); const request = new Request('https://example.com/api/test'); const result = await provider.verifyToken(request); assertEquals(result.valid, false); assertEquals(result.error, undefined); // no header is not an error});Key Files Reference
| File | Purpose |
|---|---|
worker/lib/auth.ts | Better Auth factory — all plugins, adapters, social providers, additionalFields |
worker/middleware/better-auth-provider.ts | IAuthProvider implementation wrapping Better Auth |
worker/middleware/auth.ts | Three-tier request auth chain + guard functions |
worker/types.ts | IAuthProvider, IAuthContext, IAuthProviderResult, UserTier, AuthScope, Env |
worker/handlers/auth-providers.ts | GET /api/auth/providers — returns active provider config |
prisma/schema.prisma | Database schema including all Better Auth tables and custom fields |
Related Documentation
- Better Auth User Guide — End-user flows
- Better Auth Admin Guide — Admin operations
- Developer Guide — Architecture overview, binding reference
- Better Auth Prisma — Adapter and Hyperdrive setup
- Social Providers — OAuth provider setup details
- Configuration Guide — Full environment variable reference
Bloqr AI™ — The privacy you didn't know you needed.
© 2026 Bloqr AI™, a trademark of Bloqr Systems™. Created by Bloqr Systems™, founded by Jayson Knight.
Internet Hygiene (n.) — the ongoing practices that keep your digital life clean, private, and safe.
Our product repos live in the BloqrAI org, part of the Bloqr Systems GitHub Enterprise. Product repos are internal-visibility — enterprise membership is required to view them.