Organization permissions
Check organization membership and roles in Convex functions with the Better Auth organization plugin.
Result
Better Auth stores organizations, members, and invitations. Your Convex functions read the caller's membership and role before every read or write. The frontend reads the caller's permissions only to show or hide controls.
This recipe uses the default Better Auth roles: owner, admin, and
member. It needs a local auth component. Follow
Better Auth plugins first.
Turn on organizations
Write the organization options once, in schemaPlugins.ts:
import { jwt, organization, type OrganizationOptions } from 'better-auth/plugins'
export function createOrganizationOptions() {
return {
requireEmailVerificationOnInvitation: true,
} as const satisfies OrganizationOptions
}
export function createAuthSchemaPlugins(authIssuer: string) {
return [
organization(createOrganizationOptions()),
jwt({
disableSettingJwtHeader: true,
jwks: {
disablePrivateKeyEncryption: false,
gracePeriod: 21 * 60,
keyPairConfig: { alg: 'RS256' },
},
jwt: { audience: authIssuer, expirationTime: '10m', issuer: authIssuer },
}),
]
}Pass the same options to the auth factory:
import { createBetterConvexAuth } from '@lupinum/better-convex-nuxt/better-auth/server'
import { components } from './_generated/api'
import type { DataModel } from './_generated/dataModel'
import { createOrganizationOptions } from './betterAuth/schemaPlugins'
export const auth = createBetterConvexAuth<DataModel>(components.betterAuth, {
organization: createOrganizationOptions(),
})requireEmailVerificationOnInvitation: true makes a user verify the invited
email address before accepting an invitation. Keep it a fixed value. Do not
read it from an environment variable or a request. Invitation email goes
through the email hook; see
Transactional auth email.
Regenerate the auth schema after you change these options:
pnpm exec better-convex auth schema \
--config convex/betterAuth/schemaOptions.ts \
--output convex/betterAuthShare the role rules
Put the role rules in one file that the backend and the frontend can import:
export type OrganizationRole = 'owner' | 'admin' | 'member'
export function canCreateProject(role: string) {
return role === 'owner' || role === 'admin'
}The file only describes the rules. The member row in Convex decides the role.
Better Auth can store several roles in one row as a comma-separated string,
such as admin,member. This recipe gives each member one role. If you assign
several, split the string before you check it.
Load the membership in Convex
Read the member row from the Better Auth component with the signed-in user's ID:
import { ConvexError } from 'convex/values'
import { components } from '../_generated/api'
import type { MutationCtx, QueryCtx } from '../_generated/server'
import { auth } from '../auth'
type Member = { id: string; organizationId: string; userId: string; role: string }
export async function requireOrganizationMember(
ctx: QueryCtx | MutationCtx,
organizationId: string,
) {
const user = await auth.requireUser(ctx)
const member = (await ctx.runQuery(components.betterAuth.adapter.findOne, {
model: 'member',
where: [
{ field: 'organizationId', value: organizationId },
{ field: 'userId', value: user.id },
],
})) as Member | null
if (!member) throw new ConvexError({ code: 'ORGANIZATION_NOT_FOUND' })
return { user, member }
}A non-member gets the same error as a missing organization. This does not reveal which organizations exist.
Check the role in a mutation
import { ConvexError, v } from 'convex/values'
import { canCreateProject } from '../shared/roles'
import { mutation } from './_generated/server'
import { requireOrganizationMember } from './lib/organizations'
export const create = mutation({
args: { organizationId: v.string(), name: v.string() },
handler: async (ctx, args) => {
const { user, member } = await requireOrganizationMember(ctx, args.organizationId)
if (!canCreateProject(member.role)) {
throw new ConvexError({ code: 'FORBIDDEN', message: 'You cannot create projects.' })
}
return await ctx.db.insert('projects', {
organizationId: args.organizationId,
name: args.name.trim(),
createdBy: user.id,
})
},
})Create an organization
Call the Better Auth organization API from a mutation. auth.getAuth(ctx)
returns a Better Auth instance and headers that act as the signed-in user:
import { v } from 'convex/values'
import { mutation } from './_generated/server'
import { auth } from './auth'
export const create = mutation({
args: { name: v.string(), slug: v.string() },
handler: async (ctx, args) => {
const { auth: betterAuth, headers } = await auth.getAuth(ctx)
const organization = await betterAuth.api.createOrganization({
headers,
body: { name: args.name, slug: args.slug },
})
return { id: organization.id, name: organization.name }
},
})The creator becomes the owner. In the browser, you can also call
client.organization.create() after you add organizationClient() to
app/convex-auth.ts.
Show allowed controls
Return the caller's role and permissions from a query. The page uses them to hide buttons. The mutation still repeats the check:
export const capabilities = query({
args: { organizationId: v.string() },
handler: async (ctx, args) => {
const { member } = await requireOrganizationMember(ctx, args.organizationId)
return { role: member.role, canCreateProject: canCreateProject(member.role) }
},
})Import query from ./_generated/server, requireOrganizationMember from
./lib/organizations, and canCreateProject from ../shared/roles.
Test every role
Call the Convex functions directly as each of these callers:
- anonymous;
- a user who is not a member;
member,admin, andowner;- a member of a different organization.
The last case catches a function that checks membership in the wrong
organization. starters/team is a complete example with teams and custom
roles.