Pattern: Better Auth Integration
Integrate Better Auth for OAuth-based session management in Lambda handlers —
getAuthinitializes a cached auth instance with your Drizzle schema, social providers sign in or register users in a single call, andexpireSession/extractBearerTokenhandle logout without custom SQL.
The Pattern
// src/domain/auth/authInstance.ts — shared cached instance (session ops)
import { getAuth } from '@mantleframework/auth'
import { getRequiredEnv } from '@mantleframework/env'
import { getDrizzleClient } from '#db/client'
import { accounts, sessions, users, verification } from '#db/schema'
export async function getAuthInstance() {
return getAuth(getDrizzleClient, {
secret: getRequiredEnv('AUTH_SECRET'),
baseURL: getRequiredEnv('AUTH_BASE_URL'),
schema: { user: users, session: sessions, account: accounts, verification },
})
}// src/lambdas/api/user/login.post.ts — Sign in with Apple via ID token
import { getAuth } from '@mantleframework/auth'
import { buildValidatedResponse, defineLambda } from '@mantleframework/core'
import { getRequiredEnv } from '@mantleframework/env'
import { defineApiHandler, z } from '@mantleframework/validation'
import { getDrizzleClient } from '#db/client'
import { accounts, sessions, users, verification } from '#db/schema'
import { userLoginResponseSchema } from '#types/api-schema'
import type { GetSessionResult, SignInSocialTokenResult } from '#types/betterAuth'
defineLambda({
secrets: {
AUTH_SECRET: 'platform.key',
APPLE_CLIENT_ID: 'signInWithApple.config',
APPLE_CLIENT_SECRET: 'signInWithApple.authKey',
},
staticEnvVars: { APPLE_APP_BUNDLE_IDENTIFIER: 'lifegames.OfflineMediaDownloader' },
})
const LoginRequestSchema = z.object({ idToken: z.string() })
const api = defineApiHandler({ auth: 'none', schema: LoginRequestSchema, operationName: 'LoginUser' })
export const handler = api(async ({ event, context, body }) => {
const auth = await getAuth(getDrizzleClient, {
secret: getRequiredEnv('AUTH_SECRET'),
baseURL: getRequiredEnv('AUTH_BASE_URL'),
schema: { user: users, session: sessions, account: accounts, verification },
socialProviders: {
apple: {
clientId: getRequiredEnv('APPLE_CLIENT_ID'),
clientSecret: getRequiredEnv('APPLE_CLIENT_SECRET'),
appBundleIdentifier: getRequiredEnv('APPLE_APP_BUNDLE_IDENTIFIER'),
},
},
})
// signInSocial handles both login and registration in the ID token flow
const rawResult = await auth.api.signInSocial({
headers: {
'user-agent': event.headers?.['User-Agent'] || '',
'x-forwarded-for': event.requestContext?.identity?.sourceIp || '',
},
body: { provider: 'apple', idToken: { token: body.idToken } },
})
const result = rawResult as SignInSocialTokenResult
// signInSocial returns { token, user } only — use getSession to retrieve session metadata
const sessionResult = await auth.api.getSession({
headers: new Headers({ Authorization: `Bearer ${result.token}` }),
}) as GetSessionResult | null
return buildValidatedResponse(context, 200, {
token: result.token,
expiresAt: new Date(sessionResult!.session.expiresAt).toISOString(),
sessionId: sessionResult!.session.id,
userId: result.user.id,
}, userLoginResponseSchema)
})// src/lambdas/api/user/logout.post.ts — expire session via Better Auth
import { expireSession, extractBearerToken } from '@mantleframework/auth'
import { buildValidatedResponse, defineLambda } from '@mantleframework/core'
import { defineApiHandler } from '@mantleframework/validation'
import { getDrizzleClient } from '#db/client'
import { getAuthInstance } from '#domain/auth/authInstance'
defineLambda({ secrets: { AUTH_SECRET: 'platform.key' } })
const api = defineApiHandler({ auth: 'authorizer', operationName: 'LogoutUser' })
export const handler = api(async ({ event, context, userId }) => {
const token = extractBearerToken(event.headers?.['Authorization'] || event.headers?.['authorization'])
const auth = await getAuthInstance()
const db = await getDrizzleClient()
await expireSession(auth, token, db)
return buildValidatedResponse(context, 204)
})How It Works
getAuth from @mantleframework/auth wraps Better Auth with a Drizzle adapter. The schema mapping (user, session, account, verification) tells Better Auth which tables to use — three of the four are re-exported from @mantleframework/auth/schema so they stay in sync with the framework. Social providers (Apple in this case) are only configured inline in the handlers that need them (login, register); the shared getAuthInstance helper omits them for session-only operations like logout and refresh, keeping cold-start overhead low.
The signInSocial call in the ID-token flow (mobile → server) handles both first-time registration and subsequent logins identically — Better Auth creates the user row on first sign-in and returns an existing one thereafter. Because signInSocial only returns { token, user }, a second getSession call retrieves the session metadata needed to return expiresAt and sessionId to the client.
Logout uses expireSession + extractBearerToken from @mantleframework/auth rather than custom SQL — this sets expiresAt = now() on the session row, preserving it for the scheduled cleanup Lambda.
Real-World Usage
- Auth instance factory:
src/domain/auth/authInstance.ts - Login handler:
src/lambdas/api/user/login.post.ts - Register handler:
src/lambdas/api/user/register.post.ts - Logout handler:
src/lambdas/api/user/logout.post.ts
Configuration
// src/db/schema.ts — map Better Auth tables to your Drizzle schema
export const users = pgTable('users', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').notNull(),
image: text('image'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
})
// Re-export framework-managed tables directly
export { authSessions as sessions } from '@mantleframework/auth/schema'
export { authAccounts as accounts } from '@mantleframework/auth/schema'
export { authVerification as verification } from '@mantleframework/auth/schema'// defineLambda secrets — loaded from SOPS-encrypted secrets file at deploy time
defineLambda({
secrets: {
AUTH_SECRET: 'platform.key',
APPLE_CLIENT_ID: 'signInWithApple.config',
APPLE_CLIENT_SECRET: 'signInWithApple.authKey',
},
})Variations
- Register with profile data: After
signInSocial, detect new users (createdAtwithin ~5 seconds of now) and callupdateUserwith name fields passed from the iOS app — seeregister.post.tsfor the full pattern. - Session validation in authorizer: For the API Gateway Lambda authorizer, use direct DB queries via
getSessionByToken(from#entities/queries) instead ofgetAuth— avoids Better Auth initialization overhead on every request. - Shared instance without social providers: Use
getAuthInstance()in any handler that only needs session operations (validate, expire, refresh) — omittingsocialProviderskeeps the initialization lean.