Skip to main content

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:

convex/betterAuth/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:

convex/auth.ts
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:

bash
pnpm exec better-convex auth schema \
  --config convex/betterAuth/schemaOptions.ts \
  --output convex/betterAuth

Share the role rules

Put the role rules in one file that the backend and the frontend can import:

shared/roles.ts
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:

convex/lib/organizations.ts
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

convex/projects.ts
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:

convex/organizations.ts
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:

convex/organizations.ts
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, and owner;
  • 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.