Skip to content

Pattern: Better Auth Integration

Integrate Better Auth for OAuth-based session management in Lambda handlers — getAuth initializes a cached auth instance with your Drizzle schema, social providers sign in or register users in a single call, and expireSession/extractBearerToken handle logout without custom SQL.

The Pattern

typescript
// 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 },
  })
}
typescript
// 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)
})
typescript
// 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

Configuration

typescript
// 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'
typescript
// 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 (createdAt within ~5 seconds of now) and call updateUser with name fields passed from the iOS app — see register.post.ts for the full pattern.
  • Session validation in authorizer: For the API Gateway Lambda authorizer, use direct DB queries via getSessionByToken (from #entities/queries) instead of getAuth — 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) — omitting socialProviders keeps the initialization lean.