Better-Auth Integration

A complete setup with better-auth: email/password and OAuth sign-in, organizations as tenants, platform roles and per-organization roles driving auto-api permissions, and a Nuxt UI front end.

Better-Auth Integration

A complete setup with better-auth: email/password and OAuth sign-in, organizations as tenants, platform roles and per-organization roles driving auto-api permissions, and a Nuxt UI front end.

auto-api does not authenticate anyone itself — it reads who the caller is from the request context. This guide wires better-auth into that context once, and every resource, custom endpoint and composable picks it up.

Core authorization concepts (permission values, listFilter, objectLevel, field rules) are in Authentication & Authorization; recipes in the Permissions Cookbook.

1. Install

npm install better-auth

2. The server auth instance

// server/utils/auth.ts
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { admin, organization } from 'better-auth/plugins'
import * as schema from '../database/schema'

let _auth: ReturnType<typeof betterAuth> | undefined

// Lazily created: on Cloudflare the D1 binding only exists once the Worker runs.
export function serverAuth() {
  _auth ??= betterAuth({
    database: drizzleAdapter(useDrizzle(), { provider: 'sqlite', schema }), // your Drizzle instance
    emailAndPassword: { enabled: true },
    socialProviders: {
      github: { clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET! },
    },
    plugins: [
      admin(), // platform roles on user.role ('admin', 'user', …)
      organization(), // organizations, members (owner/admin/member), invitations, active org on the session
    ],
  })
  return _auth
}

Generate the better-auth tables into your Drizzle schema with npx @better-auth/cli generate, then run your migrations as usual.

3. Mount the auth routes

// server/api/auth/[...all].ts
export default defineEventHandler(event => serverAuth().handler(toWebRequest(event)))
Do not register better-auth's own tables (user, session, account, member, …) as auto-api resources with write access. better-auth owns them; expose what the UI needs through its client API, or register a table read-only with a tight listFilter.

4. Tell auto-api who the caller is

The session lookup happens in a context extender, which runs inside the auto-api pipeline before authorization. The built-in plugin does it:

// server/autoapi-plugins.ts
import { createBetterAuthPlugin } from '@websideproject/nuxt-auto-api/plugins'

export default [
  createBetterAuthPlugin({
    getSession: event => serverAuth().api.getSession({ headers: event.headers }),
  }),
]
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@websideproject/nuxt-auto-api'],
  autoApi: {
    plugins: '~/server/autoapi-plugins',
  },
})

By default every request then has:

FieldFrom
ctx.user.id, ctx.user.emailthe better-auth user
ctx.user.rolesuser.roles, or [user.role] (the admin plugin's platform role)
ctx.user.organizationIdsession.activeOrganizationId — the organization plugin keeps the active org on the session
ctx.permissionsuser.permissions (empty unless you add them — see below)

Anonymous requests leave ctx.user null. The plugin never overrides a user another plugin already set (an API token, for example).

Mapping roles to permissions

Permission strings (update: 'posts:write') are matched against ctx.permissions. better-auth has roles, not permission lists, so map one to the other with getPermissions. Both mapUser and getPermissions receive the session as their second argument:

const ROLE_PERMISSIONS: Record<string, string[]> = {
  admin: ['*'], // '*' passes every operation except an explicit `false`
  editor: ['posts:write', 'posts:publish'],
  user: [],
}

createBetterAuthPlugin({
  getSession: event => serverAuth().api.getSession({ headers: event.headers }),
  getPermissions: user => String(user.role ?? 'user').split(',').flatMap(r => ROLE_PERMISSIONS[r.trim()] ?? []),
})

The caller's role in the active organization

The member role (owner / admin / member) lives on better-auth's member row, not on the session. Two ways to get it onto the context:

Per request — simplest, one query:

createBetterAuthPlugin({
  getSession: async (event) => {
    const session = await serverAuth().api.getSession({ headers: event.headers })
    if (!session?.session.activeOrganizationId) return session
    const member = await serverAuth().api.getActiveMember({ headers: event.headers }).catch(() => null)
    return { ...session, user: { ...session.user, orgRole: member?.role ?? null } }
  },
  mapUser: (user, session) => ({
    id: user.id,
    email: user.email,
    roles: [user.role ?? 'user'],
    orgRole: user.orgRole, // 'owner' | 'admin' | 'member' | null
    organizationId: session?.activeOrganizationId ?? null, // a custom mapper must copy the tenant across
  }),
})

Cached on the user — zero queries per request. Keep a { [orgId]: role } map on the user as an additionalFields column (it is then part of the session cookie cache), refresh it from the organization plugin's membership hooks, and read map[session.activeOrganizationId] in mapUser. This is what the SaaS boilerplate does; it pays off once every request goes through auto-api.

5. Organizations are tenants

// nuxt.config.ts
autoApi: {
  multiTenancy: {
    enabled: true,
    tenantIdField: 'organizationId', // column on scoped tables, read from ctx.user.organizationId
    excludedResources: ['plans'],
  },
},
// server/database/schema.ts — better-auth ids are strings
export const projects = sqliteTable('projects', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  name: text('name').notNull(),
  organizationId: text('organization_id').notNull().references(() => organization.id, { onDelete: 'cascade' }),
  createdBy: text('created_by'),
})

Every route on projects is now filtered to the active organization, organizationId is stamped on create and can never be changed by a request body, and a signed-in user with no active organization is refused (403) rather than shown every organization. See Multi-Tenancy.

6. Permissions from platform and organization roles

// modules/projects/auth.ts
import type { HandlerContext, ResourceAuthConfig } from '@websideproject/nuxt-auto-api'

const orgRole = (ctx: HandlerContext) => ctx.user?.orgRole as 'owner' | 'admin' | 'member' | null | undefined
const isMember = (ctx: HandlerContext) => !!orgRole(ctx)
const isOrgAdmin = (ctx: HandlerContext) => orgRole(ctx) === 'owner' || orgRole(ctx) === 'admin'

export const projectsAuth: ResourceAuthConfig = {
  permissions: {
    read: isMember,
    create: isMember,
    update: isMember,
    delete: isOrgAdmin,
  },
  // Members edit their own projects; org admins edit any project of the organization.
  objectLevel: (project, ctx) =>
    ctx.operation === 'list' || ctx.operation === 'get' || project.createdBy === ctx.user?.id || isOrgAdmin(ctx),
  fields: {
    budget: { read: isOrgAdmin, write: isOrgAdmin }, // hidden from members; writing it is a 403
  },
}

Tenant scoping runs before objectLevel, so these rules only ever see rows of the active organization.

7. The client

// app/composables/useAuth.ts
import { createAuthClient } from 'better-auth/vue'
import { adminClient, organizationClient } from 'better-auth/client/plugins'

export const authClient = createAuthClient({ plugins: [adminClient(), organizationClient()] })

export function useAuth() {
  const session = authClient.useSession()
  return {
    session,
    user: computed(() => session.value.data?.user ?? null),
    activeOrganizationId: computed(() => session.value.data?.session.activeOrganizationId ?? null),
    client: authClient,
  }
}

Sign-in page with UAuthForm

<!-- app/pages/login.vue -->
<script setup lang="ts">
import { z } from 'zod'
import type { FormSubmitEvent } from '@nuxt/ui'

const { client } = useAuth()
const route = useRoute()
const toast = useToast()
const loading = ref(false)

const fields = [
  { name: 'email', type: 'email' as const, label: 'Email', required: true },
  { name: 'password', type: 'password' as const, label: 'Password', required: true },
]
const providers = [{
  label: 'GitHub',
  icon: 'i-simple-icons-github',
  onClick: () => client.signIn.social({ provider: 'github', callbackURL: String(route.query.redirect ?? '/app') }),
}]
const schema = z.object({ email: z.string().email(), password: z.string().min(8) })

async function onSubmit(event: FormSubmitEvent<z.output<typeof schema>>) {
  loading.value = true
  const { error } = await client.signIn.email(event.data)
  loading.value = false
  if (error) return toast.add({ title: 'Sign-in failed', description: error.message, color: 'error' })
  await navigateTo(String(route.query.redirect ?? '/app'))
}
</script>

<template>
  <UPageCard class="mx-auto mt-24 max-w-md">
    <UAuthForm
      title="Welcome back"
      icon="i-lucide-lock"
      :fields="fields"
      :providers="providers"
      :schema="schema"
      :loading="loading"
      :submit="{ label: 'Sign in' }"
      @submit="onSubmit"
    />
  </UPageCard>
</template>

Organization switcher

Switching the active organization changes what every tenant-scoped query returns, so drop the cached auto-api data afterwards:

<!-- app/components/OrgSwitcher.vue -->
<script setup lang="ts">
const { client, activeOrganizationId } = useAuth()
const queryClient = useQueryClient()
const orgs = client.useListOrganizations()

const items = computed(() => (orgs.value.data ?? []).map(o => ({ label: o.name, value: o.id, avatar: o.logo ? { src: o.logo } : undefined })))

async function select(id: string) {
  await client.organization.setActive({ organizationId: id })
  await queryClient.invalidateQueries({ queryKey: ['autoapi'] }) // every auto-api list/get/aggregate
  await queryClient.invalidateQueries({ queryKey: ['permissions'] })
}
</script>

<template>
  <USelectMenu
    :model-value="activeOrganizationId ?? undefined"
    :items="items"
    value-key="value"
    placeholder="Select a workspace"
    class="w-56"
    @update:model-value="select"
  />
</template>

Protecting pages

// app/middleware/auth.ts — use with definePageMeta({ middleware: 'auth' })
export default defineNuxtRouteMiddleware(async (to) => {
  const { data } = await authClient.getSession({ fetchOptions: { headers: useRequestHeaders(['cookie']) } })
  if (!data) return navigateTo(`/login?redirect=${encodeURIComponent(to.fullPath)}`)
})

Page guards are UX. The API enforces the same rules on every request whatever the page does.

Data, with the session

<script setup lang="ts">
const { data } = useAutoApiList('projects', { sort: '-createdAt' })
const { canCreate } = usePermissions('projects') // the server's answer for THIS caller
</script>

<template>
  <UButton v-if="canCreate" label="New project" to="/app/projects/new" />
  <UPageGrid>
    <UPageCard v-for="p in data?.data" :key="p.id" :title="p.name" :to="`/app/projects/${p.id}`" />
  </UPageGrid>
</template>

The session cookie is sent with every request, including during SSR — the composables forward the incoming request's headers — so a server-rendered page shows the signed-in user's data.

8. Testing

Drive the real routes with a test-only identity instead of real sessions — see Testing. Unit-test mapUser / getPermissions as plain functions.

Troubleshooting

Every tenant-scoped request is 403 "An active tenant is required" — the session has no activeOrganizationId. Set one after sign-in (organization.setActive) or give users a default organization. A custom mapUser must copy session.activeOrganizationId onto the user.

update: 'admin' is always 403 — permission strings match ctx.permissions, which is empty unless getPermissions fills it. Map roles to permissions, or use a function (ctx => ctx.user?.roles?.includes('admin')).

Data from the previous organization after switching — invalidate the ['autoapi'] queries (above).

Need a Landing Page?

Modern landing pages with optional modules (blog, docs, forms, i18n). Let's discuss your project.

Build Your MVP

Full-stack SaaS development. Expert in database design, multi-tenancy, and scalable architecture.

Deployment Help

Dockerize your backend, set up CI/CD pipelines, deploy to Cloudflare or Hetzner. Early-stage setup.

Suggest a SaaS Tool

Missing a calculator or tool? Suggest what you'd like to see on our site.