Skip to content

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.

PluginPackageRoute PrefixWhat It Adds
dash()@better-auth/infraBetter 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/pluginsAuthorization: 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-sessionsConcurrent 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/pluginsRejects sign-up/reset passwords found in known breaches (k-anonymity API check, no config)
lastLoginMethod({ storeInDatabase: true })better-auth/pluginsTracks 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/jwksShort-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.

PluginPackageGateWhat It Adds
sentinel({ apiKey, kvUrl, security })@better-auth/infraBETTER_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/pluginsTURNSTILE_SECRET_KEY presentCloudflare Turnstile bot protection on /sign-up/email, /sign-in/email, /request-password-reset
apiKey(...)@better-auth/api-keyBETTER_AUTH_APIKEY_ENABLED=trueThe 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/pluginsBETTER_AUTH_MCP_ENABLED=trueOAuth provider for MCP clients; mutually exclusive with oidcProvider() (enforced at startup)
oidcProvider({ loginPage, allowDynamicClientRegistration, useJWTPlugin })better-auth/pluginsBETTER_AUTH_OIDC_ENABLED=trueMakes bloqr an OIDC provider for third-party/enterprise clients; mutually exclusive with mcp()
sso()@better-auth/ssoBETTER_AUTH_SSO_ENABLED=trueEnterprise customer SAML/OIDC login; ssoProvider table
scim({ providerOwnership: { enabled: true } })@better-auth/scimBETTER_AUTH_SCIM_ENABLED=trueInbound SCIM user provisioning from an enterprise identity provider; scimProvider table
passkey({ rpID, rpName })@better-auth/passkeyBETTER_AUTH_PASSKEY_ENABLED=true + BETTER_AUTH_PASSKEY_RP_IDWebAuthn; 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.

PluginPackageRoute PrefixWhat It Adds
magicLink(...)better-auth/plugins/api/auth/sign-in/magic-link, /api/auth/magic-link/verifyPasswordless 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

PluginPackageWhy
stripe(...)@better-auth/stripeInstalled 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.

PluginPackagePrerequisiteWhat 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):

Terminal window
# 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:migrate

Step 4 — Run the tests:

Terminal window
deno task test:worker

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).

PluginPackageWhy Skipped
username()better-auth/pluginsEmail auth is sufficient; no username-based UX planned
phoneNumber()better-auth/pluginsNo SMS/phone sign-in requirement
anonymous()better-auth/pluginsNo anonymous-session-then-claim UX planned
siwe()better-auth/pluginsNo wallet-based (Ethereum) auth requirement
oauthProxy()better-auth/pluginsNo proxy-OAuth deployment topology in use
oneTap()better-auth/pluginsGoogle One Tap not part of the current sign-in UX
i18n()better-auth/pluginsNo multi-locale UI currently planned

User Model Extension

The user block in worker/lib/auth.ts configures two things:

  1. fields — maps Better Auth’s canonical field names to the Prisma model’s field names.
  2. 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:

worker/lib/auth.ts
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 fields is removed or the Prisma model fields are renamed, every sign-up and OAuth sign-in will return a 500. The exported USER_FIELD_MAPPING constant 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

  1. Edit worker/lib/auth.ts, add the field to user.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 },
},
  1. 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 name as a column name in the user model — it conflicts with Better Auth’s internal name field. Use displayName (mapped via USER_FIELD_MAPPING) instead.

  1. Run the migration:
Terminal window
deno task db:migrate # creates and applies migration
deno task db:generate # regenerates Prisma client
  1. Update worker/types.ts if 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

worker/lib/auth.ts
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

AdapterPackageWhen to Use
prismaAdapter (current)better-auth/adapters/prismaNeon/Supabase PostgreSQL via Cloudflare Hyperdrive
d1Adapterbetter-auth/adapters/d1Cloudflare D1 (SQLite) — simpler, no Hyperdrive
drizzleAdapterbetter-auth/adapters/drizzleDrizzle ORM with D1, Turso, or LibSQL
kyselybetter-auth/adapters/kyselyRaw 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

worker/middleware/auth0-provider.ts
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" tier
app.use('/api/export/bulk', requireTier(UserTier.PRO));
// Require a specific scope
app.use('/api/compile', requireScope(AuthScope.COMPILE));

Adding a Social Provider

Step 1 — Register the OAuth App

For GitHub:

  1. Go to GitHub → Settings → Developer Settings → OAuth Apps → New OAuth App
  2. Set Authorization callback URL: https://your-worker.workers.dev/api/auth/callback/github
  3. Copy the Client ID and generate a Client Secret

Step 2 — Add Wrangler Secrets

Terminal window
wrangler secret put GITHUB_CLIENT_ID
wrangler secret put GITHUB_CLIENT_SECRET

Step 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,
},
}
: {}),
},
Terminal window
wrangler secret put GOOGLE_CLIENT_ID
wrangler secret put GOOGLE_CLIENT_SECRET

Step 4 — Deploy and Verify

Terminal window
wrangler deploy
curl 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

FileWhat It Tests
worker/lib/auth.test.tscreateAuth() factory — missing secrets, config validation
worker/middleware/auth.test.tsauthenticateRequestUnified(), requireAuth(), requireTier(), requireScope()
worker/middleware/better-auth-provider.test.tsBetterAuthProvider.verifyToken() — valid sessions, expired sessions, missing headers
worker/middleware/auth-extensibility.test.tsIAuthProvider contract compliance
worker/middleware/zta-auth-gates.test.tsZTA tier/scope enforcement — no trust from JWT claims
worker/handlers/auth-config.test.tsGET /api/auth/providers response
worker/handlers/auth-admin.test.tsAdmin endpoints — list users, ban, revoke

Running Auth Tests

Terminal window
# All tests
deno task test:worker
# A specific file
deno test --allow-read --allow-write --allow-net --allow-env worker/middleware/auth.test.ts
# With coverage
deno task test:coverage

Test 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

FilePurpose
worker/lib/auth.tsBetter Auth factory — all plugins, adapters, social providers, additionalFields
worker/middleware/better-auth-provider.tsIAuthProvider implementation wrapping Better Auth
worker/middleware/auth.tsThree-tier request auth chain + guard functions
worker/types.tsIAuthProvider, IAuthContext, IAuthProviderResult, UserTier, AuthScope, Env
worker/handlers/auth-providers.tsGET /api/auth/providers — returns active provider config
prisma/schema.prismaDatabase schema including all Better Auth tables and custom fields