Better-Auth Integration
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.
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)))
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:
| Field | From |
|---|---|
ctx.user.id, ctx.user.email | the better-auth user |
ctx.user.roles | user.roles, or [user.role] (the admin plugin's platform role) |
ctx.user.organizationId | session.activeOrganizationId — the organization plugin keeps the active org on the session |
ctx.permissions | user.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).
Authentication & Authorization
@websideproject/nuxt-auto-api works with any authentication provider: your app tells it who the caller is, and every generated route enforces the rules you declare per resource.
Nested Relations
Nuxt Auto API supports advanced nested relation loading with field selection, filtering, and pagination at each level of nesting.