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 @j0nathan-ll0yd/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 '@j0nathan-ll0yd/core'
import { defineApiHandler, z } from '@j0nathan-ll0yd/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 (@j0nathan-ll0yd/auth) ​

For user-facing applications that need OAuth, social login, and session management, use @j0nathan-ll0yd/auth. It wraps Better Auth with a Drizzle adapter configured for Lambda.

typescript
// src/domain/auth/authInstance.ts — shared cached instance
import { getAuth } from '@j0nathan-ll0yd/auth'
import { getRequiredEnv } from '@j0nathan-ll0yd/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 '@j0nathan-ll0yd/auth'
import { defineApiHandler, z } from '@j0nathan-ll0yd/validation'
import { buildValidatedResponse } from '@j0nathan-ll0yd/core'
import { getRequiredEnv } from '@j0nathan-ll0yd/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 @j0nathan-ll0yd/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 '@j0nathan-ll0yd/auth/schema'
export { authAccounts as accounts } from '@j0nathan-ll0yd/auth/schema'
export { authVerification as verification } from '@j0nathan-ll0yd/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 @j0nathan-ll0yd/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 ​