Auth Security

Standard Role Template

Opinionated starter scaffold for role-based permissions with Better Auth + Convex + Nuxt.
Opinionated starter for fast adoption. Customize roles, resources, and checks to match your product.
Need the full walkthrough? See Permissions Setup.

What You Get

This scaffold gives you a canonical org-based model:

  • Roles: owner, admin, member, viewer
  • Shared checkPermission() logic for frontend + backend
  • Backend authorize() guard for mutations/actions
  • Frontend usePermissions() composable via createPermissions

1) Enable Permission Helpers

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['better-convex-nuxt'],
  convex: {
    permissions: true,
  },
})

2) Shared Permission Config

convex/permissions.config.ts
export const ROLES = ['owner', 'admin', 'member', 'viewer'] as const
export type Role = (typeof ROLES)[number]

export const permissions = {
  global: {
    'org.settings': { roles: ['owner'] },
    'org.invite': { roles: ['owner', 'admin'] },
    'org.members': { roles: ['owner', 'admin'] },
  },
  post: {
    create: { roles: ['owner', 'admin', 'member'] },
    read: { roles: ['owner', 'admin', 'member', 'viewer'] },
    update: { own: ['member'], any: ['owner', 'admin'] },
    delete: { own: ['member'], any: ['owner', 'admin'] },
    publish: { roles: ['owner', 'admin'] },
  },
} as const

type GlobalPermission = keyof (typeof permissions)['global']
type PostPermission = `post.${keyof (typeof permissions)['post']}`
export type Permission = GlobalPermission | PostPermission

export interface PermissionContext {
  role: Role
  userId: string
  organizationId: string | null
}

export interface Resource {
  ownerId?: string
  organizationId?: string
}

type PermissionRule = { roles: readonly Role[] } | { own: readonly Role[]; any: readonly Role[] }

export function checkPermission(
  ctx: PermissionContext | null,
  permission: Permission,
  resource?: Resource,
): boolean {
  if (!ctx) return false

  if (permission in permissions.global) {
    const rule = permissions.global[permission as GlobalPermission]
    return rule.roles.includes(ctx.role)
  }

  const [resourceType, action] = permission.split('.') as [string, string]
  const resourcePerms = permissions[resourceType as keyof typeof permissions]
  if (!resourcePerms || resourceType === 'global') return false

  const rule = (resourcePerms as Record<string, PermissionRule>)[action]
  if (!rule) return false

  if ('roles' in rule) {
    return rule.roles.includes(ctx.role)
  }

  if (rule.any.includes(ctx.role)) return true

  if (!resource?.ownerId) return false
  return rule.own.includes(ctx.role) && resource.ownerId === ctx.userId
}

3) Backend Authorization Helper

convex/lib/permissions.ts
import { ConvexError } from 'convex/values'
import type { MutationCtx, QueryCtx, ActionCtx } from '../_generated/server'
import {
  checkPermission,
  type Permission,
  type Resource,
  type PermissionContext,
} from '../permissions.config'

type Ctx = MutationCtx | QueryCtx | ActionCtx

async function getPermissionContext(ctx: Ctx): Promise<PermissionContext | null> {
  const identity = await ctx.auth.getUserIdentity()
  if (!identity) return null

  const user = await ctx.db
    .query('users')
    .withIndex('by_auth_id', (q) => q.eq('authId', identity.subject))
    .first()

  if (!user) return null

  return {
    role: user.role,
    userId: identity.subject,
    organizationId: user.organizationId ?? null,
  }
}

export async function authorize(
  ctx: Ctx,
  permission: Permission,
  resource?: Resource,
): Promise<PermissionContext> {
  const context = await getPermissionContext(ctx)

  if (!context) {
    throw new ConvexError('Unauthorized')
  }

  if (
    resource?.organizationId &&
    context.organizationId &&
    resource.organizationId !== context.organizationId
  ) {
    throw new ConvexError('Forbidden')
  }

  if (!checkPermission(context, permission, resource)) {
    throw new ConvexError('Forbidden')
  }

  return context
}

4) Permission Context Query

convex/auth.ts
import { query } from './_generated/server'

export const getPermissionContext = query({
  args: {},
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity()
    if (!identity) return null

    const user = await ctx.db
      .query('users')
      .withIndex('by_auth_id', (q) => q.eq('authId', identity.subject))
      .first()

    if (!user) return null

    return {
      userId: identity.subject,
      role: user.role,
      organizationId: user.organizationId ?? null,
      isAuthenticated: true,
    }
  },
})

5) Frontend Composable

composables/usePermissions.ts
import { api } from '~~/convex/_generated/api'
import { checkPermission, type Permission, type Resource } from '~~/convex/permissions.config'

export const usePermissions = createPermissions<
  { role: 'owner' | 'admin' | 'member' | 'viewer'; userId: string; organizationId: string | null },
  Permission,
  Resource
>({
  query: api.auth.getPermissionContext,
  checkPermission,
})

Use in components:

<script setup lang="ts">
const { can, role } = usePermissions()
</script>

<template>
  <button v-if="can('post.create')">New Post</button>
  <button v-if="can('org.invite')">Invite Member</button>
</template>

Next Customizations

  • Add resource groups beyond post (for example comment, invoice, project)
  • Tighten org checks in authorize() for all org-scoped resources
  • Replace broad Forbidden errors with domain-specific errors when needed