Adapters
Adapters are the data access layer in AuthHero that abstract database operations and platform-specific functionality. They provide a consistent interface for storing and retrieving authentication data across different storage backends.
Why Adapters?
AuthHero is designed to run on various platforms and databases:
- Traditional servers with PostgreSQL, MySQL, or SQLite
- Serverless environments with managed databases
- Edge computing on Cloudflare Workers with D1
- Custom deployments with specialized storage needs
Adapters make this possible by implementing a standard interface while handling platform-specific details internally.
Adapter Types
Database Adapters
Database adapters handle CRUD operations for authentication data:
- Kysely - SQL databases via type-safe query builder (full Lucene query support)
- Drizzle - SQL databases via modern ORM
- AWS - DynamoDB with single-table design (basic filtering, no Lucene queries)
Platform Adapters
Platform adapters add cloud-specific capabilities:
- Cloudflare - D1 database, KV storage, custom domains, analytics
Adapter Middleware and Composition
AuthHero's adapter architecture supports middleware patterns that allow you to combine multiple adapters or add cross-cutting concerns like caching, logging, or data synchronization.
Passthrough Adapter
The createPassthroughAdapter utility enables syncing write operations to multiple adapter implementations simultaneously. This powerful pattern supports:
- Database Migration: Gradually migrate from one database to another
- Multi-destination Logging: Write logs to both database and analytics services
- Cache Warming: Keep multiple cache layers synchronized
- Search Index Sync: Update search indexes alongside primary storage
How It Works
A passthrough adapter has one primary adapter and one or more secondary adapters:
- Reads always come from the primary adapter
- Writes go to the primary adapter first, then are synced to all secondaries
- Secondaries can be blocking (wait for completion) or non-blocking (fire-and-forget)
- Secondary failures are logged but don't block the primary operation
import { createPassthroughAdapter } from "@authhero/adapter-interfaces";
import { createKyselyAdapter } from "@authhero/kysely";
import { createDrizzleAdapter } from "@authhero/drizzle";
// Primary: PostgreSQL with Kysely
const postgresAdapter = createKyselyAdapter(postgresDb);
// Secondary: MySQL with Drizzle (migration target)
const mysqlAdapter = createDrizzleAdapter(mysqlDb);
// Create combined adapter
const combinedAdapter = {
...postgresAdapter,
users: createPassthroughAdapter({
primary: postgresAdapter.users,
secondaries: [
{
adapter: mysqlAdapter.users,
blocking: true, // Wait for MySQL write to complete
onError: (error, method, args) => {
console.error(`MySQL sync failed for ${method}:`, error);
// Alert your monitoring system
},
},
],
}),
// Repeat for other entities...
};Multi-Adapter Use Cases
1. Database Migration
Run two databases in parallel during migration:
const dataAdapter = {
users: createPassthroughAdapter({
primary: oldDatabaseAdapter.users, // Read from old DB
secondaries: [
{
adapter: newDatabaseAdapter.users, // Write to new DB
blocking: true, // Ensure writes succeed before returning
},
],
}),
};
// All reads come from the old database
// All writes go to both databases
// When migration is complete, swap the primaryOnce data is fully synced, you can:
- Switch the primary and secondary to read from the new database
- Verify data integrity
- Remove the passthrough and use the new adapter directly
2. Analytics and Monitoring
Send data to analytics without affecting primary operations:
const logsAdapter = createPassthroughAdapter({
primary: databaseLogsAdapter,
secondaries: [
{
adapter: analyticsEngineAdapter,
blocking: false, // Don't wait for analytics
},
{
adapter: webhookNotifier,
blocking: false,
},
],
});3. Cache Synchronization
Keep multiple cache layers in sync:
const cacheAdapter = createPassthroughAdapter({
primary: redisCacheAdapter,
secondaries: [
{ adapter: memcachedAdapter },
{ adapter: cdnCacheAdapter },
],
syncMethods: ["set", "delete"], // Only sync write operations
});4. Multi-Cloud Resilience
Ensure high availability by running on multiple cloud providers simultaneously. If one provider experiences an outage, you can quickly failover to the other:
import { createCloudflareAdapter } from "@authhero/cloudflare";
import { createKyselyAdapter } from "@authhero/kysely";
// Primary: Cloudflare D1 (edge database)
const cloudflareAdapter = createCloudflareAdapter(cloudflareEnv);
// Secondary: AWS RDS with Kysely (regional database)
const awsAdapter = createKyselyAdapter(awsDb);
const dataAdapter = {
users: createPassthroughAdapter({
primary: cloudflareAdapter.users,
secondaries: [
{
adapter: awsAdapter.users,
blocking: true, // Ensure AWS stays in sync
onError: (error) => {
// Alert on sync failures - may indicate AWS issues
alertOpsTeam("AWS sync failed", error);
},
},
],
}),
// Configure other entities similarly...
};
// If Cloudflare has an outage, quickly switch to AWS:
// Just swap primary and secondary in your configurationBenefits:
- Zero data loss: All writes go to both providers
- Fast failover: Switch configuration to make AWS primary
- Provider independence: Not locked into a single cloud
- Regional coverage: Combine edge (Cloudflare) with regional (AWS) deployment
Considerations:
- Increased costs: Running two databases
- Latency: Blocking writes may be slower (use non-blocking if acceptable)
- Consistency: Monitor sync health to ensure both databases stay aligned
- Failover testing: Regularly test switching between providers
Configuration Options
interface PassthroughConfig<T> {
// Primary adapter - handles all reads and initial writes
primary: T;
// Secondary adapters to sync writes to
secondaries: Array<{
// Can be a partial implementation - only implemented methods are called
adapter: Partial<T>;
// Wait for this secondary before returning? Default: false
blocking?: boolean;
// Error handler for failed secondary writes
onError?: (error: Error, method: string, args: unknown[]) => void;
}>;
// Which methods trigger secondary syncing
// Default: ["create", "update", "remove", "delete", "set"]
syncMethods?: string[];
}Best Practices
- Use blocking for critical secondaries: If data consistency matters (like during migration), set
blocking: true - Monitor secondary failures: Always implement
onErrorhandlers to track sync issues - Partial implementations: Secondaries don't need to implement all methods - only implement what you need
- Gradual rollout: Migrate one entity at a time (start with logs, then users, etc.)
- Verify before switching: Monitor secondary writes for errors before promoting to primary
See the Database Migration Guide for a complete step-by-step migration example.
Writing a Custom Adapter
1. Implement the Core Interfaces
All adapters must implement the interfaces defined in @authhero/adapter-interfaces:
import {
Users,
Tenants,
Applications,
Connections,
// ... other interfaces
} from "@authhero/adapter-interfaces";
export interface MyDatabaseAdapter {
users: Users;
tenants: Tenants;
applications: Applications;
connections: Connections;
// ... other required entities
}The complete list of required interfaces can be found in the Adapter Interfaces documentation.
2. Handle Database-Specific Errors
Adapters should catch database-specific errors and convert them to standard HTTP exceptions. This ensures consistent error handling across the application:
import { HTTPException } from "hono/http-exception";
export function create(db: MyDatabase) {
return async (tenantId: string, user: User): Promise<User> => {
try {
await db.insert("users", user);
} catch (error: any) {
// Convert database unique constraint errors to 409 Conflict
if (
error?.code === "UNIQUE_VIOLATION" ||
error?.message?.includes("duplicate key")
) {
throw new HTTPException(409, {
message: "User already exists",
});
}
// Convert other known errors
if (error?.code === "FOREIGN_KEY_VIOLATION") {
throw new HTTPException(400, {
message: "Invalid reference to related entity",
});
}
// Re-throw unknown errors
throw error;
}
return user;
};
}Why use HTTPException?
HTTPException from Hono flows through the entire request pipeline automatically. When an adapter throws an HTTPException:
- Hono's error handler catches it
- The HTTP status code and message are sent to the client
- No route-level error handling is needed
This pattern is used throughout AuthHero's adapters (see users/create.ts, tenants/create.ts, etc.).
3. Implement Query Filtering
Many list operations support Lucene-style query syntax for filtering.
Note: Not all adapters support Lucene queries. For example, the AWS adapter uses DynamoDB which only supports basic filtering. If advanced queries aren't needed for your use case, you can implement simpler filtering logic:
import { luceneFilter } from "./helpers/filter";
export function list(db: MyDatabase) {
return async (params: { q?: string; page?: number; per_page?: number }) => {
let query = db.selectFrom("users");
// Apply Lucene-style filtering if query provided
if (params.q) {
query = luceneFilter(db, query, params.q, ["email", "name"]);
}
// Apply pagination
const page = params.page ?? 0;
const perPage = params.per_page ?? 50;
query = query.limit(perPage).offset(page * perPage);
const users = await query.execute();
return { users };
};
}The luceneFilter helper supports:
- Field-specific searches:
email:user@example.com - AND queries:
email:user@example.com name:John - OR queries:
email:user1@example.com OR email:user2@example.com - Comparison operators:
login_count:>5 - Negation:
-name:John - Quoted values:
name:"John Doe"
See kysely/src/helpers/filter.ts for the reference implementation.
4. Handle Multi-Tenancy
All database operations must be scoped to a tenant to ensure data isolation:
export function get(db: MyDatabase) {
return async (tenantId: string, userId: string): Promise<User | null> => {
const user = await db
.selectFrom("users")
.where("tenant_id", "=", tenantId) // Always filter by tenant
.where("user_id", "=", userId)
.selectAll()
.executeTakeFirst();
return user || null;
};
}Critical for security:
- Always include tenant_id in WHERE clauses
- Never expose cross-tenant data
- Validate tenant_id is provided before queries
5. Type Safety
Use TypeScript strictly and leverage the provided Zod schemas for validation:
import { userSchema, type User } from "@authhero/adapter-interfaces";
export function create(db: MyDatabase) {
return async (tenantId: string, userData: unknown): Promise<User> => {
// Validate input using Zod schema
const user = userSchema.parse(userData);
// Type-safe database operations
const inserted = await db.insertInto("users")
.values({
...user,
tenant_id: tenantId,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.returningAll()
.executeTakeFirstOrThrow();
return inserted;
};
}6. Testing Your Adapter
Write comprehensive tests for your adapter using Vitest:
import { describe, it, expect, beforeEach } from "vitest";
import { createMyAdapter } from "./index";
describe("MyAdapter - Users", () => {
let adapter: MyDatabaseAdapter;
beforeEach(async () => {
adapter = await createMyAdapter({
// Test database config
});
});
it("should create a user", async () => {
const user = await adapter.users.create("tenant1", {
email: "test@example.com",
// ... other fields
});
expect(user.email).toBe("test@example.com");
expect(user.tenant_id).toBe("tenant1");
});
it("should throw 409 for duplicate users", async () => {
await adapter.users.create("tenant1", { email: "test@example.com" });
await expect(
adapter.users.create("tenant1", { email: "test@example.com" })
).rejects.toThrow(HTTPException);
});
it("should filter by tenant", async () => {
await adapter.users.create("tenant1", { email: "user1@example.com" });
await adapter.users.create("tenant2", { email: "user2@example.com" });
const users = await adapter.users.list("tenant1", {});
expect(users.users).toHaveLength(1);
expect(users.users[0].email).toBe("user1@example.com");
});
});Example: Minimal Adapter Structure
Here's a minimal adapter structure to get started:
import { Kysely } from "kysely";
import { HTTPException } from "hono/http-exception";
import type {
Users,
Tenants,
Applications,
Connections,
Logs,
Sessions,
Codes,
Passwords,
} from "@authhero/adapter-interfaces";
import { Database } from "./db"; // Your database schema
export function createMyAdapter(db: Kysely<Database>) {
return {
users: createUsersAdapter(db),
tenants: createTenantsAdapter(db),
applications: createApplicationsAdapter(db),
connections: createConnectionsAdapter(db),
logs: createLogsAdapter(db),
sessions: createSessionsAdapter(db),
codes: createCodesAdapter(db),
passwords: createPasswordsAdapter(db),
// ... other required adapters
};
}
function createUsersAdapter(db: Kysely<Database>): Users {
return {
create: async (tenantId, user) => {
try {
const result = await db
.insertInto("users")
.values({ ...user, tenant_id: tenantId })
.returningAll()
.executeTakeFirstOrThrow();
return result;
} catch (error: any) {
if (error?.code === "UNIQUE_VIOLATION") {
throw new HTTPException(409, { message: "User already exists" });
}
throw error;
}
},
get: async (tenantId, userId) => {
const user = await db
.selectFrom("users")
.where("tenant_id", "=", tenantId)
.where("user_id", "=", userId)
.selectAll()
.executeTakeFirst();
return user || null;
},
list: async (tenantId, params) => {
const users = await db
.selectFrom("users")
.where("tenant_id", "=", tenantId)
.selectAll()
.execute();
return { users };
},
update: async (tenantId, userId, updates) => {
const user = await db
.updateTable("users")
.set(updates)
.where("tenant_id", "=", tenantId)
.where("user_id", "=", userId)
.returningAll()
.executeTakeFirstOrThrow();
return user;
},
remove: async (tenantId, userId) => {
await db
.deleteFrom("users")
.where("tenant_id", "=", tenantId)
.where("user_id", "=", userId)
.execute();
},
};
}
// Implement other adapters similarly...Best Practices
- Error Handling: Always convert database errors to HTTPException
- Tenant Isolation: Never forget to filter by tenant_id
- Type Safety: Use TypeScript strictly and leverage Zod schemas
- Testing: Write comprehensive tests for all CRUD operations
- Consistency: Follow the patterns used in existing adapters
- Documentation: Document adapter-specific configuration and limitations
- Performance: Use database indexes and optimize queries
- Transactions: Use transactions for multi-step operations when needed
Reference Implementations
Study these reference implementations:
- Kysely Adapter - Comprehensive SQL adapter with advanced filtering
- AWS Adapter - NoSQL adapter example without Lucene query support
- Cloudflare Adapter - Edge-optimized adapter with platform integrations
Contributing
If you've built a custom adapter that others might find useful, consider contributing it to the AuthHero ecosystem. See the Contributing Guide for details.