Arraf Auth uses the adapter pattern to support multiple database solutions. Adapters provide a consistent interface for database operations, allowing you to use your preferred ORM or database without changing your authentication logic.
What is a Database Adapter?
A database adapter is an object that implements the DatabaseAdapter interface, providing methods to interact with your database for authentication-related operations.
interface DatabaseAdapter {
// User operations
createUser ( data : Omit < User , 'id' | 'createdAt' | 'updatedAt' >) : Promise < User >
findUserById ( id : string ) : Promise < User | null >
findUserByEmail ( email : string ) : Promise < User | null >
findUserByPhone ( phone : string ) : Promise < User | null >
updateUser ( id : string , data : Partial < User >) : Promise < User >
deleteUser ( id : string ) : Promise < void >
// Session operations
createSession ( data : Omit < Session , 'id' | 'createdAt' >) : Promise < Session >
findSession ( token : string ) : Promise < Session | null >
updateSession ( token : string , data : Partial < Session >) : Promise < Session >
deleteSession ( token : string ) : Promise < void >
deleteUserSessions ( userId : string ) : Promise < void >
// Account operations (OAuth, credentials)
createAccount ( data : Omit < Account , 'id' | 'createdAt' >) : Promise < Account >
findAccount ( providerId : string , accountId : string ) : Promise < Account | null >
findAccountsByUserId ( userId : string ) : Promise < Account []>
// Verification operations (OTP, email verification)
createVerification ( data : Omit < Verification , 'id' | 'createdAt' >) : Promise < Verification >
findVerification ( identifier : string , type : VerificationType ) : Promise < Verification | null >
updateVerification ( id : string , data : Partial < Verification >) : Promise < Verification >
deleteVerification ( id : string ) : Promise < void >
deleteExpiredVerifications () : Promise < void >
}
See packages/core/src/types.ts:96-116 for the complete interface definition.
Why Use Adapters?
The adapter pattern provides several benefits:
Database Agnostic Use any database or ORM. Arraf Auth doesn’t lock you into a specific solution.
Easy Testing Create mock adapters for testing without a real database.
Flexible Schema Extend the default schema with custom fields while maintaining compatibility.
Future Proof Switch databases or ORMs without changing authentication logic.
Available Adapters
Arraf Auth provides official adapters for popular ORMs:
The Prisma adapter provides seamless integration with Prisma ORM. Installation: npm install @arraf-auth/prisma-adapter
Usage: import { PrismaClient } from '@prisma/client'
import { prismaAdapter } from '@arraf-auth/prisma-adapter'
import { createAuth } from '@arraf-auth/core'
const prisma = new PrismaClient ()
export const auth = createAuth ({
secret: process . env . AUTH_SECRET ! ,
database: prismaAdapter ( prisma )
})
Implementation:
The adapter maps Prisma operations to the DatabaseAdapter interface. For example:// packages/adapters/prisma/src/index.ts:20-23
async createUser ( data ) {
const user = await client . user . create ({ data })
return user as User
}
// packages/adapters/prisma/src/index.ts:54-57
async findSession ( token ) {
const session = await client . session . findUnique ({ where: { token } })
return session as Session | null
}
Type Conversion:
The adapter handles type conversion for verification types (underscore to hyphen):// packages/adapters/prisma/src/index.ts:10-16
function toVerificationType ( type : string ) : VerificationType {
return type . replace ( '_' , '-' ) as VerificationType
}
function fromVerificationType ( type : VerificationType ) : string {
return type . replace ( '-' , '_' )
}
Learn more: Prisma Adapter API The Drizzle adapter provides integration with Drizzle ORM for PostgreSQL. Installation: npm install @arraf-auth/drizzle-adapter
Usage: import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import { drizzleAdapter } from '@arraf-auth/drizzle-adapter'
import { createAuth } from '@arraf-auth/core'
const client = postgres ( process . env . DATABASE_URL ! )
const db = drizzle ( client )
export const auth = createAuth ({
secret: process . env . AUTH_SECRET ! ,
database: drizzleAdapter ( db )
})
Implementation:
The adapter uses Drizzle’s query builder for type-safe database operations:// packages/adapters/drizzle/src/index.ts:36-39
async createUser ( data ) {
const [ user ] = await db . insert ( users ). values ( data ). returning ()
return user as User
}
// packages/adapters/drizzle/src/index.ts:77-82
async findSession ( token ) {
const [ session ] = await db
. select ()
. from ( sessions )
. where ( eq ( sessions . token , token ))
return ( session as Session ) ?? null
}
Schema:
The adapter includes pre-defined schema tables:import { users , sessions , accounts , verifications } from '@arraf-auth/drizzle-adapter'
Learn more: Drizzle Adapter API
Required Database Schema
Your database must include the following tables to work with Arraf Auth:
Users Table
interface User {
id : string // Primary key
email : string | null // Unique, nullable
phone : string | null // Unique, nullable
name : string | null
emailVerified : boolean
phoneVerified : boolean
image : string | null
createdAt : Date
updatedAt : Date
}
Sessions Table
interface Session {
id : string // Primary key
userId : string // Foreign key to User
token : string // Unique
expiresAt : Date
ipAddress ?: string
userAgent ?: string
createdAt : Date
}
Accounts Table
interface Account {
id : string // Primary key
userId : string // Foreign key to User
providerId : string // 'phone', 'credential', 'google', etc.
accountId : string // Provider-specific user ID
accessToken ?: string // OAuth access token or password hash
refreshToken ?: string // OAuth refresh token
accessTokenExpiresAt ?: Date
createdAt : Date
}
Unique constraint on (providerId, accountId).
Verifications Table
interface Verification {
id : string // Primary key
identifier : string // Email or phone number
token : string // OTP code or verification token
type : VerificationType // 'phone-otp', 'email-otp', etc.
expiresAt : Date
attempts : number
createdAt : Date
}
type VerificationType =
| 'phone-otp'
| 'email-otp'
| 'email-verification'
| 'password-reset'
| 'phone-change'
Unique constraint on (identifier, type).
See the full schema definitions in the adapter source code:
Prisma: Your Prisma schema file
Drizzle: packages/adapters/drizzle/src/schema.ts
Creating a Custom Adapter
You can create a custom adapter for any database or ORM by implementing the DatabaseAdapter interface:
import type { DatabaseAdapter , User , Session , Account , Verification , VerificationType } from '@arraf-auth/core'
import { yourORM } from 'your-orm'
export function customAdapter ( client : any ) : DatabaseAdapter {
return {
// User operations
async createUser ( data ) {
const user = await client . users . insert ( data )
return user
},
async findUserById ( id ) {
const user = await client . users . findOne ({ id })
return user || null
},
async findUserByEmail ( email ) {
const user = await client . users . findOne ({ email })
return user || null
},
async findUserByPhone ( phone ) {
const user = await client . users . findOne ({ phone })
return user || null
},
async updateUser ( id , data ) {
const user = await client . users . update ( id , data )
return user
},
async deleteUser ( id ) {
await client . users . delete ( id )
},
// Session operations
async createSession ( data ) {
const session = await client . sessions . insert ( data )
return session
},
async findSession ( token ) {
const session = await client . sessions . findOne ({ token })
return session || null
},
async updateSession ( token , data ) {
const session = await client . sessions . update ({ token }, data )
return session
},
async deleteSession ( token ) {
await client . sessions . delete ({ token })
},
async deleteUserSessions ( userId ) {
await client . sessions . deleteMany ({ userId })
},
// Account operations
async createAccount ( data ) {
const account = await client . accounts . insert ( data )
return account
},
async findAccount ( providerId , accountId ) {
const account = await client . accounts . findOne ({ providerId , accountId })
return account || null
},
async findAccountsByUserId ( userId ) {
const accounts = await client . accounts . findMany ({ userId })
return accounts
},
// Verification operations
async createVerification ( data ) {
const verification = await client . verifications . insert ( data )
return verification
},
async findVerification ( identifier , type ) {
const verification = await client . verifications . findOne ({ identifier , type })
return verification || null
},
async updateVerification ( id , data ) {
const verification = await client . verifications . update ( id , data )
return verification
},
async deleteVerification ( id ) {
await client . verifications . delete ( id )
},
async deleteExpiredVerifications () {
await client . verifications . deleteMany ({
expiresAt: { $lt: new Date () }
})
}
}
}
Adapter Implementation Guidelines
Ensure your adapter returns the correct types. Use type assertions if needed: async findUserById ( id : string ): Promise < User | null > {
const user = await client . user . findOne ({ id })
return user as User | null // Type assertion
}
Let errors propagate to the auth layer. Don’t catch errors unless you need to transform them: // ✅ Good: Let errors propagate
async createUser ( data ) {
return await client . user . create ( data )
}
// ❌ Bad: Swallowing errors
async createUser ( data ) {
try {
return await client . user . create ( data )
} catch ( error ) {
return null // Don't do this
}
}
Ensure your database enforces unique constraints:
users.email (unique, nullable)
users.phone (unique, nullable)
sessions.token (unique)
accounts(providerId, accountId) (composite unique)
verifications(identifier, type) (composite unique)
Some databases may not support certain types. Handle conversions in your adapter: // Example: SQLite doesn't support booleans
async createUser ( data ) {
return await client . user . create ({
... data ,
emailVerified: data . emailVerified ? 1 : 0 ,
phoneVerified: data . phoneVerified ? 1 : 0
})
}
async findUserById ( id ) {
const user = await client . user . findOne ({ id })
return {
... user ,
emailVerified: Boolean ( user . emailVerified ),
phoneVerified: Boolean ( user . phoneVerified )
}
}
You can implement soft deletes by marking records as deleted instead of removing them: async deleteSession ( token ) {
await client . sessions . update (
{ token },
{ deletedAt: new Date () }
)
}
async findSession ( token ) {
const session = await client . sessions . findOne ({
token ,
deletedAt: null
})
return session || null
}
Adapter Testing
Test your custom adapter thoroughly:
import { describe , it , expect , beforeEach } from 'vitest'
import { customAdapter } from './custom-adapter'
describe ( 'Custom Adapter' , () => {
let adapter : DatabaseAdapter
beforeEach ( async () => {
// Setup test database
adapter = customAdapter ( testClient )
})
it ( 'creates and finds user by email' , async () => {
const user = await adapter . createUser ({
email: '[email protected] ' ,
phone: null ,
name: 'Test User' ,
emailVerified: false ,
phoneVerified: false ,
image: null
})
expect ( user . id ). toBeDefined ()
expect ( user . email ). toBe ( '[email protected] ' )
const found = await adapter . findUserByEmail ( '[email protected] ' )
expect ( found ?. id ). toBe ( user . id )
})
it ( 'creates and finds session' , async () => {
const user = await adapter . createUser ({ /* ... */ })
const session = await adapter . createSession ({
userId: user . id ,
token: 'session_token_123' ,
expiresAt: new Date ( Date . now () + 86400000 )
})
expect ( session . id ). toBeDefined ()
expect ( session . userId ). toBe ( user . id )
const found = await adapter . findSession ( 'session_token_123' )
expect ( found ?. id ). toBe ( session . id )
})
// Test all adapter methods...
})
Indexing Ensure proper database indexes on:
users.email
users.phone
sessions.token
sessions.userId
accounts(providerId, accountId)
verifications(identifier, type)
Connection Pooling Use connection pooling for better performance: const pool = new Pool ({
max: 20 ,
idleTimeoutMillis: 30000
})
Query Optimization Select only needed fields: // Instead of SELECT *
const user = await db . select (
'id' , 'email' , 'name'
). from ( users )
Cleanup Jobs Schedule cleanup of expired verifications: cron . schedule ( '0 * * * *' , async () => {
await adapter . deleteExpiredVerifications ()
})
Extending the Schema
You can extend the default schema with custom fields:
// Extend the User interface
interface ExtendedUser extends User {
role : 'user' | 'admin'
status : 'active' | 'suspended'
lastLoginAt : Date | null
}
// Update your adapter to handle custom fields
export function customAdapter ( client : any ) : DatabaseAdapter {
return {
async createUser ( data ) {
// Add default values for custom fields
const user = await client . users . insert ({
... data ,
role: 'user' ,
status: 'active' ,
lastLoginAt: null
})
return user
},
// ... other methods
}
}
When extending the schema, ensure your custom fields are optional or have default values to maintain compatibility with the core authentication flows.
Next Steps
Prisma Adapter Complete API reference for Prisma adapter
Drizzle Adapter Complete API reference for Drizzle adapter
Core API DatabaseAdapter interface reference