Authentication
Mantle supports two authentication modes: built-in static bearer tokens for machine-to-machine APIs, and full session-based auth via @mantleframework/auth (Better Auth) for user-facing applications.
Bearer token auth
Set auth: 'bearer' on defineApiHandler to validate every request against a static token before your handler runs.
import { buildValidatedResponse } from '@mantleframework/core'
import { defineApiHandler, z } from '@mantleframework/validation'
const ResponseSchema = z.object({ items: z.array(z.unknown()) })
const api = defineApiHandler({ auth: 'bearer', operationName: 'ListItems' })
export const handler = api(async ({ event, context }) => {
const items = await ItemQueries.getAll()
return buildValidatedResponse(context, 200, { items }, ResponseSchema)
})The client must send:
Authorization: Bearer <token>How it works
defineApiHandlerchecksoptions.auth === 'bearer'- Calls
validateStaticBearerToken(event, getRequiredEnv('API_BEARER_TOKEN')) - Extracts the token from the
Authorizationheader - Compares it using
crypto.timingSafeEqual(constant-time — no timing attacks) - Throws
UnauthorizedError→ returned as401 { "message": "Unauthorized" }if token is missing, malformed, or wrong
Auth is validated before body parsing. If auth fails, the body is never read.
Environment setup
API_BEARER_TOKEN must be set in the Lambda environment. mantle generate infra wires it automatically when it detects getRequiredEnv('API_BEARER_TOKEN') in your handler.
Declare it in your Terraform variables file:
variable "api_bearer_token" {
description = "API_BEARER_TOKEN environment variable"
type = string
sensitive = true
}Combining auth with body validation
const api = defineApiHandler({
schema: SyncPayloadSchema,
auth: 'bearer',
operationName: 'SyncData',
})
export const handler = api(async ({ event, context, body }) => {
// body is typed from SyncPayloadSchema — only reached after auth passes
return buildValidatedResponse(context, 200, { synced: body.items.length }, ResponseSchema)
})Execution order:
- Observability setup (metrics, tracing, logging)
- Bearer token validation
- Body parsing and Zod validation
- Your handler function
When to use 'bearer' vs 'none'
| Scenario | Auth mode |
|---|---|
| iOS app syncing data | 'bearer' |
| Internal service-to-service calls | 'bearer' |
| Health check / ping endpoints | 'none' |
| Webhook receivers (validate signature in handler) | 'none' |
| Public read endpoints | 'none' |
Use 'bearer' for any endpoint that modifies data or returns user-specific information.
Better Auth (@mantleframework/auth)
For user-facing applications that need OAuth, social login, and session management, use @mantleframework/auth. It wraps Better Auth with a Drizzle adapter configured for Lambda.
// src/domain/auth/authInstance.ts — shared cached instance
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 { defineApiHandler, z } from '@mantleframework/validation'
import { buildValidatedResponse } from '@mantleframework/core'
import { getRequiredEnv } from '@mantleframework/env'
import { getDrizzleClient } from '#db/client'
import { accounts, sessions, users, verification } from '#db/schema'
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 first-time registration and subsequent logins
const result = await auth.api.signInSocial({
headers: { 'x-forwarded-for': event.requestContext?.identity?.sourceIp || '' },
body: { provider: 'apple', idToken: { token: body.idToken } },
})
return buildValidatedResponse(context, 200, { token: result.token, userId: result.user.id }, ResponseSchema)
})Schema mapping
Three of the four Better Auth tables are re-exported from @mantleframework/auth/schema to stay in sync with the framework. Only users is defined locally (so you can extend it with application fields).
// src/db/schema.ts
export const users = pgTable('users', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').notNull(),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
})
export { authSessions as sessions } from '@mantleframework/auth/schema'
export { authAccounts as accounts } from '@mantleframework/auth/schema'
export { authVerification as verification } from '@mantleframework/auth/schema'Key APIs
| Import | Description |
|---|---|
getAuth(getDrizzleClient, config) | Create (and cache) a Better Auth instance |
expireSession(auth, token, db) | Expire a session by token — sets expiresAt = now() |
extractBearerToken(header) | Parse Authorization: Bearer <token> header |
validateSession(auth, token) | Validate a session token and return session data |
Always import from @mantleframework/auth — never from better-auth directly. See the Vendor Encapsulation Policy for details.
Secrets configuration
Better Auth requires secrets loaded from SOPS at deploy time. Declare them in defineLambda:
defineLambda({
secrets: {
AUTH_SECRET: 'platform.key',
APPLE_CLIENT_ID: 'signInWithApple.config',
APPLE_CLIENT_SECRET: 'signInWithApple.authKey',
},
})Next steps
- Pattern: Better Auth Integration — full login/logout/register examples from media-downloader
- Handlers — handler factory options including
auth - Observability — how auth errors are logged and traced