Skip to content

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.

typescript
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

  1. defineApiHandler checks options.auth === 'bearer'
  2. Calls validateStaticBearerToken(event, getRequiredEnv('API_BEARER_TOKEN'))
  3. Extracts the token from the Authorization header
  4. Compares it using crypto.timingSafeEqual (constant-time — no timing attacks)
  5. Throws UnauthorizedError → returned as 401 { "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:

hcl
variable "api_bearer_token" {
  description = "API_BEARER_TOKEN environment variable"
  type        = string
  sensitive   = true
}

Combining auth with body validation

typescript
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:

  1. Observability setup (metrics, tracing, logging)
  2. Bearer token validation
  3. Body parsing and Zod validation
  4. Your handler function

When to use 'bearer' vs 'none'

ScenarioAuth 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.

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

typescript
// 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

ImportDescription
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:

typescript
defineLambda({
  secrets: {
    AUTH_SECRET: 'platform.key',
    APPLE_CLIENT_ID: 'signInWithApple.config',
    APPLE_CLIENT_SECRET: 'signInWithApple.authKey',
  },
})

Next steps