Authentication Developer Guide
Technical reference for developers working on or extending the bloqr-backend authentication system.
Architecture
Authentication Flow
The unified auth middleware runs on every protected request. Token type is determined by the
blq_ prefix (or legacy abc_ prefix) — API keys and Better Auth session tokens are mutually exclusive.
flowchart TD
A["Request arrives at Worker"] --> B["authenticateRequestUnified()\nworker/middleware/auth.ts"]
B --> C{Has Bearer token\nor session cookie?}
C -->|No| D["Anonymous\nauthMethod: 'anonymous'\ntier: anonymous"]
C -->|Yes| E{"Bearer token starts\nwith 'blq_' or 'abc_' prefix?"}
E -->|Yes| F["API Key path\nSHA-256 hash → PostgreSQL lookup"]
E -->|No| G["BetterAuthProvider.verifyToken()\nauth.api.getSession() via Prisma + Neon"]
F --> H["IAuthContext built\n{ userId, tier, role, apiKeyId, scopes,\nauthMethod: 'api-key' }"]
G --> H2["IAuthContext built\n{ userId, tier, role, sessionId,\nauthMethod: 'better-auth' }"]
H --> I["Route Handlers\nrequireAuth()\nrequireTier()\nrequireScope()"]
H2 --> I
D --> I
Key Files
| File | Purpose |
|---|---|
worker/types.ts | Type definitions: IAuthProvider, IAuthContext, UserTier, AuthScope, Env |
worker/lib/auth.ts | Better Auth factory — createAuth(env, baseURL) |
worker/lib/prisma.ts | PrismaClient factory — creates per-request client via Hyperdrive |
worker/middleware/auth.ts | Unified auth middleware, authenticateRequestUnified(), guards |
worker/middleware/better-auth-provider.ts | BetterAuthProvider — IAuthProvider implementation |
worker/middleware/cf-access.ts | Cloudflare Access JWT verification (defense-in-depth) |
worker/handlers/api-keys.ts | API key CRUD endpoints |
worker/handlers/auth-providers.ts | GET /api/auth/providers — active provider metadata |
The IAuthProvider Interface
The authentication system is built on an extensible provider interface defined in worker/types.ts.
You can swap or add identity providers without changing middleware or route handlers.
Interface Definition
export interface IAuthProvider { /** Human-readable provider name, e.g. 'better-auth', 'auth0' */ readonly name: string;
/** Auth method identifier stamped onto IAuthContext for every verified request */ readonly authMethod: IAuthContext['authMethod'];
/** Verify credentials from the incoming request and return user identity */ verifyToken(request: Request): Promise<IAuthProviderResult>;}
export interface IAuthProviderResult { readonly valid: boolean; readonly providerUserId?: string; readonly tier?: UserTier; readonly role?: string; readonly sessionId?: string | null; readonly error?: string; readonly email?: string | null; readonly displayName?: string | null;}
export interface IAuthContext { readonly userId: string | null; readonly tier: UserTier; readonly role: string; readonly apiKeyId: string | null; readonly sessionId: string | null; readonly scopes: readonly string[]; readonly authMethod: 'api-key' | 'anonymous' | 'better-auth'; readonly email?: string | null; readonly displayName?: string | null; readonly apiKeyRateLimit?: number | null;}Default Implementation: BetterAuthProvider
BetterAuthProvider delegates all credential verification to Better Auth’s auth.api.getSession(),
which handles both cookie-based (browser) and Bearer token (API) sessions transparently.
// worker/middleware/better-auth-provider.ts (simplified)export class BetterAuthProvider implements IAuthProvider { readonly name = 'better-auth'; readonly authMethod = 'better-auth' as const;
constructor(private readonly env: Env) {}
async verifyToken(request: Request): Promise<IAuthProviderResult> { const url = new URL(request.url); const auth = createAuth(this.env, url.origin);
// auth.api.getSession() reads cookies OR the Authorization header. // The bearer() plugin enables the Authorization: Bearer <token> path. const session = await auth.api.getSession({ headers: request.headers });
if (!session) { return { valid: false }; // No session — fall through to anonymous }
return { valid: true, providerUserId: session.user.id, tier: resolveTier(session.user.tier), // from additionalFields role: resolveRole(session.user.role), // from additionalFields sessionId: session.session.id, email: session.user.email ?? null, displayName: session.user.name ?? null, }; }}Key design points:
- Stateless per request — a fresh Better Auth instance is created for every call; Hyperdrive ensures this is cheap (local proxy socket, not a remote TCP handshake).
- ZTA-safe tier resolution — tier and role come from the database on every request, not from JWT claims. Privilege changes take effect immediately.
- Graceful fallback — a missing session returns
{ valid: false }(not an error), allowing the middleware to fall through to anonymous.
Creating a Custom Auth Provider
To integrate a third-party identity provider (Auth0, Okta, Supabase Auth, Firebase Auth):
import type { Env, IAuthProvider, IAuthProviderResult } from '../types.ts';import { UserTier } from '../types.ts';
export class MyAuthProvider implements IAuthProvider { readonly name = 'my-provider'; readonly authMethod = 'better-auth' as const; // reuse, or extend the union type
constructor(private readonly env: Env) {}
async verifyToken(request: Request): Promise<IAuthProviderResult> { const authHeader = request.headers.get('Authorization'); if (!authHeader?.startsWith('Bearer ')) { return { valid: false }; }
const token = authHeader.slice(7); try { const payload = await verifyMyJwt(token, this.env.MY_JWKS_URL); return { valid: true, providerUserId: payload.sub, tier: mapTier(payload.plan) as UserTier, role: payload.role ?? 'user', email: payload.email ?? null, displayName: payload.name ?? null, }; } catch { return { valid: false, error: 'Token verification failed' }; } }}Register it in worker/hono-app.ts by replacing the BetterAuthProvider instantiation:
import { MyAuthProvider } from './middleware/my-auth-provider.ts';
app.use('*', async (c, next) => { const authProvider = new MyAuthProvider(c.env); // ← swap here // ... rest of middleware setup});Adding Better Auth Plugins
Better Auth plugins are added to the plugins array inside createAuth() in worker/lib/auth.ts.
Each plugin registers routes under /api/auth/* and may add database tables.
Current Plugins
plugins: [ bearer(), // Authorization: Bearer <session-token> path for API clients twoFactor({ issuer: 'bloqr-backend' }), // TOTP 2FA multiSession(), // Multiple concurrent sessions admin(), // Admin user management endpoints]Adding apiKey()
Built-in API key management (replaces the custom api_keys table if desired):
import { apiKey } from 'better-auth/plugins';
plugins: [ bearer(), twoFactor({ issuer: 'bloqr-backend' }), multiSession(), admin(), apiKey(), // adds /api/auth/api-key/create, /list, /revoke]After adding, run deno task db:generate to update the Prisma schema, then deno task db:migrate.
Adding organization()
Multi-tenancy support:
import { organization } from 'better-auth/plugins';
plugins: [ // ... existing plugins organization(), // adds /api/auth/organization/* and member/* routes]This adds organization and member tables — always run deno task db:generate && deno task db:migrate after adding any plugin.
Adding passkey()
WebAuthn / FIDO2 passkey support:
import { passkey } from 'better-auth/plugins';
plugins: [ // ... existing plugins passkey(), // adds /api/auth/passkey/register, /authenticate]Swapping the Database Adapter
The database adapter is the single line database: prismaAdapter(prisma, { provider: 'postgresql' }).
Changing it requires no other code changes in the auth system.
Current: Neon PostgreSQL via Hyperdrive
// worker/lib/auth.ts — current configurationimport { prismaAdapter } from 'better-auth/adapters/prisma';import { createPrismaClient } from './prisma.ts';
const prisma = createPrismaClient(env.HYPERDRIVE.connectionString);
betterAuth({ database: prismaAdapter(prisma, { provider: 'postgresql' }), // ...});Cloudflare D1 (edge SQLite)
import { d1Adapter } from 'better-auth/adapters/d1';
betterAuth({ database: d1Adapter(env.DB), // ...});Add the D1 binding to wrangler.toml:
[[d1_databases]]binding = "DB"database_name = "adblock-auth"database_id = "<your-d1-id>"MySQL / PlanetScale
import { prismaAdapter } from 'better-auth/adapters/prisma';// Use a MySQL-compatible PrismaClient
betterAuth({ database: prismaAdapter(prisma, { provider: 'mysql' }), // ...});Self-Hosted PostgreSQL (without Hyperdrive)
import { prismaAdapter } from 'better-auth/adapters/prisma';// PrismaClient created with direct DATABASE_URL (not Hyperdrive)
betterAuth({ database: prismaAdapter(prisma, { provider: 'postgresql' }), // ...});Custom Adapter
Implement Better Auth’s Adapter interface directly for any data source:
import type { Adapter } from 'better-auth';
const myAdapter: Adapter = { async create(data) { /* ... */ }, async findOne(query) { /* ... */ }, async findMany(query) { /* ... */ }, async update(query, data) { /* ... */ }, async delete(query) { /* ... */ },};
betterAuth({ database: myAdapter, // ...});Extending the User Model
Better Auth’s user.additionalFields lets you add typed fields to every user record without
touching the core session/account tables.
Current Additional Fields
user: { additionalFields: { tier: { type: 'string', required: false, defaultValue: 'free', input: false, // ← prevents users from self-assigning tier }, role: { type: 'string', required: false, defaultValue: 'user', input: false, // ← prevents users from self-assigning role }, },},Adding a New Field
Step 1 — Add to additionalFields in worker/lib/auth.ts:
user: { additionalFields: { tier: { /* ... existing ... */ }, role: { /* ... existing ... */ }, displayName: { // ← new field type: 'string', required: false, defaultValue: null, input: true, // users may set their own display name }, },},Step 2 — Add the column to prisma/schema.prisma:
model User { id String @id @default(cuid()) name String email String @unique emailVerified Boolean @default(false) image String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt tier String @default("free") role String @default("user") displayName String? // ← new column // ...}Step 3 — Generate the migration:
deno task db:generatedeno task db:migrateStep 4 — Access the field from IAuthContext:
The field is automatically included in the session response. BetterAuthProvider passes it
through session.user.displayName if you add it to the return value:
return { valid: true, providerUserId: session.user.id, displayName: (session.user as { displayName?: string }).displayName ?? session.user.name, // ...};wrangler.toml Binding Requirements
| Binding | Type | Required | Description |
|---|---|---|---|
HYPERDRIVE | Hyperdrive | Yes | Neon PostgreSQL connection proxy |
BETTER_AUTH_SECRET | Secret | Yes | Session signing secret (32+ chars) |
BETTER_AUTH_URL | Var | No | Override base URL for auth endpoints |
GITHUB_CLIENT_ID | Secret | No | Enables GitHub OAuth |
GITHUB_CLIENT_SECRET | Secret | No | Enables GitHub OAuth |
GOOGLE_CLIENT_ID | Secret | No | Reserved — Google OAuth (not yet active) |
GOOGLE_CLIENT_SECRET | Secret | No | Reserved — Google OAuth (not yet active) |
TURNSTILE_SITE_KEY | Var | No | Cloudflare Turnstile public key |
TURNSTILE_SECRET_KEY | Secret | No | Cloudflare Turnstile verification key |
CF_ACCESS_TEAM_DOMAIN | Secret | No | CF Zero Trust team domain |
CF_ACCESS_AUD | Secret | No | CF Access audience tag |
CORS_ALLOWED_ORIGINS | Secret | No | Comma-separated list of allowed origins |
ANALYTICS_ENGINE | Analytics Engine | No | Workers Analytics Engine dataset |
SENTRY_DSN | Secret | No | Sentry error reporting DSN |
Minimal wrangler.toml for Better Auth:
[[hyperdrive]]binding = "HYPERDRIVE"id = "<your-hyperdrive-id>"Local Dev Setup
Copy and fill in .dev.vars (never committed to git):
# .dev.vars — Worker secrets loaded by wrangler dev
# ── Better Auth (required) ──────────────────────────────────────────────────# Generate: openssl rand -base64 32BETTER_AUTH_SECRET=your-secret-at-least-32-characters-long
# ── Hyperdrive (required) ───────────────────────────────────────────────────# Point wrangler dev at your personal Neon development branch.# Create a branch at https://console.neon.tech → your project → Branches → New Branch.# Use the "Direct connection" string (not pooled).CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE=postgresql://<user>:<password>@<branch-host>.neon.tech/<dbname>?sslmode=require
# ── GitHub OAuth (optional) ─────────────────────────────────────────────────GITHUB_CLIENT_ID=your-github-oauth-app-client-idGITHUB_CLIENT_SECRET=your-github-oauth-app-client-secret
# ── Cloudflare Turnstile (optional — use test keys locally) ─────────────────TURNSTILE_SITE_KEY=1x00000000000000000000AATURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
# ── CORS ────────────────────────────────────────────────────────────────────CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost:8787Start the dev environment:
deno task db:migrate # Apply pending Prisma migrations to your Neon branchwrangler dev # Start the WorkerSee Local Development Setup for full Neon branching instructions.
Testing
Auth Test Files
| File | What It Covers |
|---|---|
worker/lib/auth.test.ts | createAuth() factory, config validation, plugin wiring |
worker/middleware/auth.test.ts | authenticateRequestUnified() — all three auth paths |
worker/middleware/better-auth-provider.test.ts | BetterAuthProvider.verifyToken(), tier/role resolution |
worker/middleware/auth-extensibility.test.ts | IAuthProvider contract, tier/scope registries, guards |
worker/middleware/zta-auth-gates.test.ts | ZTA gate enforcement, telemetry on auth failure |
worker/handlers/auth-config.test.ts | GET /api/auth/providers response shape |
worker/handlers/auth-admin.test.ts | Admin auth operations |
Running Auth Tests
# All worker tests (includes auth)deno task test:worker
# Auth middleware onlydeno test worker/middleware/auth.test.ts worker/middleware/better-auth-provider.test.ts --allow-env --allow-net
# Auth extensibility testsdeno test worker/middleware/auth-extensibility.test.ts --allow-env
# With coveragedeno task test:coverageMocking Better Auth in Unit Tests
// Create a mock that returns a preset auth contextimport { ANONYMOUS_AUTH_CONTEXT } from '../types.ts';import type { IAuthContext, IAuthProvider, IAuthProviderResult } from '../types.ts';
export class MockAuthProvider implements IAuthProvider { readonly name = 'mock'; readonly authMethod = 'better-auth' as const;
constructor(private readonly result: IAuthProviderResult) {}
async verifyToken(_req: Request): Promise<IAuthProviderResult> { return this.result; }}
// In your test:const provider = new MockAuthProvider({ valid: true, providerUserId: 'u_123', tier: UserTier.Pro });const result = await authenticateRequestUnified(mockRequest, mockEnv, undefined, provider);assertEquals(result.context.tier, UserTier.Pro);Tier System
Registry Architecture
export enum UserTier { Anonymous = 'anonymous', Free = 'free', Pro = 'pro', Admin = 'admin',}
export const TIER_REGISTRY: Readonly<Record<UserTier, ITierConfig>> = { [UserTier.Anonymous]: { order: 0, rateLimit: 10, displayName: 'Anonymous', description: 'Unauthenticated' }, [UserTier.Free]: { order: 1, rateLimit: 60, displayName: 'Free', description: 'Registered user' }, [UserTier.Pro]: { order: 2, rateLimit: 300, displayName: 'Pro', description: 'Paid subscriber' }, [UserTier.Admin]: { order: 3, rateLimit: Infinity, displayName: 'Admin', description: 'Administrator' },};Tier comparison uses numeric order:
isTierSufficient(UserTier.Pro, UserTier.Free); // true — Pro (2) ≥ Free (1)isTierSufficient(UserTier.Free, UserTier.Admin); // false — Free (1) < Admin (3)Guard Functions
// Require any authenticated userconst authGuard = requireAuth(authContext);if (authGuard) return authGuard; // 401 Unauthorized
// Require minimum tierconst tierGuard = requireTier(authContext, UserTier.Pro);if (tierGuard) return tierGuard; // 403 Forbidden
// Require specific scopes (API key auth only)const scopeGuard = requireScope(authContext, AuthScope.Compile);if (scopeGuard) return scopeGuard; // 403 ForbiddenScope System
API key scopes provide fine-grained access control. Session-authenticated users (Better Auth) bypass scope checks — they inherit full access based on their tier.
export enum AuthScope { Compile = 'compile', Rules = 'rules', Admin = 'admin',}| Auth Method | Scope Enforcement |
|---|---|
| Better Auth session | Bypassed — tier determines access |
API key (blq_) | Enforced — key must include required scopes |
| Anonymous | 401 returned before scope check |
Related Documentation
- Better Auth User Guide — Sign-up/in, sessions, 2FA, API auth
- Better Auth Admin Guide — Bootstrap admin, user management, secrets
- Better Auth Developer Guide — Plugin architecture, adapter swapping, extensibility
- Auth Provider Selection — How the Worker selects an auth provider
- Configuration Guide — Full environment variable reference
- Social Providers — GitHub/Google OAuth setup
- Better Auth Prisma — Prisma adapter technical details
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.