Guide

Permissions

Add role-based access control with ownership rules to your app.

This guide walks you through setting up role-based permissions with ownership rules. You'll learn how to control who can create, read, update, and delete resources based on their role.

Prerequisite: Complete the Authentication guide first. You should have a working auth setup with a users table.

What the Module Provides

When you enable permissions: true in your Nuxt config, the module provides one thing: the createPermissions factory function.

import { createPermissions } from '#imports'

export const { usePermissions, usePermissionGuard } = createPermissions({
  query: api.auth.getPermissionContext,  // Your Convex query
  checkPermission: (ctx, permission, resource) => { /* Your logic */ }
})

This gives you:

  • usePermissions() - A composable that fetches permission context and provides a reactive can() function
  • usePermissionGuard() - A composable that redirects users without permission

Everything else is yours to define: roles, permission rules, the checkPermission function, and backend authorization. The module just provides the reactive Vue wrapper.

Permissions are fully optional. You don't need to use createPermissions at all. You can build your own permission system using plain useConvexQuery to fetch user roles and implement your own logic. This guide shows one approach, but feel free to adapt it or build something completely different.
Early-stage pattern. This permission approach works well for our use cases, but we haven't battle-tested it on high-traffic production sites. Consider it a starting point that you should adapt to your needs. For complex multi-tenant apps, you may want a more sophisticated solution.

What You'll Build

A permission system where:

  • Admins can manage everything
  • Members can create resources and edit their own
  • Viewers have read-only access

The permission logic is shared between frontend and backend:

┌───────────────────────────────────────────────────┐
│              permissions.config.ts                │
│         (shared permission definitions)           │
└───────────────────────┬───────────────────────────┘
                        │
        ┌───────────────┴───────────────┐
        │                               │
        ▼                               ▼
┌───────────────────┐         ┌───────────────────┐
│     Frontend      │         │      Backend      │
│  usePermissions() │         │    authorize()    │
│  can('task.edit') │         │ Permission check  │
└───────────────────┘         └───────────────────┘

Files you'll create:

FilePurpose
convex/permissions.config.tsPermission rules (shared)
convex/lib/permissions.tsBackend authorization helpers
convex/auth.tsAdd permission context query
composables/usePermissions.tsFrontend permission composable

Step 1: Add Role to Schema

Update your users table to include a role field.

convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

const roleValidator = v.union(
  v.literal('admin'),
  v.literal('member'),
  v.literal('viewer'),
)

export default defineSchema({
  users: defineTable({
    authId: v.string(),
    displayName: v.optional(v.string()),
    email: v.optional(v.string()),
    avatarUrl: v.optional(v.string()),
    role: roleValidator,  // Add this!
    createdAt: v.number(),
    updatedAt: v.number()
  })
    .index('by_auth_id', ['authId'])
    .index('by_email', ['email']),

  // Example: Tasks with ownership
  tasks: defineTable({
    text: v.string(),
    isCompleted: v.boolean(),
    ownerId: v.string(),  // Track who created it
  })
    .index('by_owner', ['ownerId']),
});

Update your auth triggers to set a default role for new users:

convex/auth.ts
// In your triggers.user.onCreate:
onCreate: async (ctx, doc) => {
  const now = Date.now()
  await ctx.db.insert('users', {
    authId: doc._id,
    displayName: doc.name,
    email: doc.email,
    avatarUrl: doc.image ?? undefined,
    role: 'member',  // Default role for new users
    createdAt: now,
    updatedAt: now
  })
}

Step 2: Define Permission Rules

Create a shared permission config used by both frontend and backend.

convex/permissions.config.ts
// Roles from most to least powerful
export const ROLES = ['admin', 'member', 'viewer'] as const
export type Role = (typeof ROLES)[number]

// Permission rules
// Simple: { roles: ['admin'] } - these roles can do it
// Ownership: { own: ['member'], any: ['admin'] } - members own stuff, admins any
export const permissions = {
  // Task permissions
  'task.create': { roles: ['admin', 'member'] },
  'task.read': { roles: ['admin', 'member', 'viewer'] },
  'task.update': { own: ['member'], any: ['admin'] },
  'task.delete': { own: ['member'], any: ['admin'] },

  // Settings (admin only)
  'settings.view': { roles: ['admin'] },
} as const

export type Permission = keyof typeof permissions

// The core check function - used by both frontend and backend
export function checkPermission(
  userRole: Role | null,
  userId: string | null,
  permission: Permission,
  resourceOwnerId?: string,
): boolean {
  if (!userRole) return false

  const rule = permissions[permission]

  // Simple permission check
  if ('roles' in rule) {
    return (rule.roles as readonly string[]).includes(userRole)
  }

  // Ownership permission check
  if ('any' in rule && (rule.any as readonly string[]).includes(userRole)) {
    return true
  }

  if ('own' in rule && (rule.own as readonly string[]).includes(userRole)) {
    return resourceOwnerId === userId
  }

  return false
}

How permissions work:

Permission RuleMeaning
{ roles: ['admin', 'member'] }Admin or member can do it
{ own: ['member'], any: ['admin'] }Members can do it to their own resources, admins to any

Step 3: Create Backend Helpers

Create utilities to enforce permissions in your Convex functions.

convex/lib/permissions.ts
import type { QueryCtx, MutationCtx } from '../_generated/server'
import { checkPermission, type Permission, type Role } from '../permissions.config'

// Get the current user with their role
export async function getUser(ctx: QueryCtx | MutationCtx) {
  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()

  return user
}

// Check permission and throw if denied
export async function authorize(
  ctx: QueryCtx | MutationCtx,
  permission: Permission,
  resourceOwnerId?: string,
) {
  const user = await getUser(ctx)
  if (!user) {
    throw new Error('Not authenticated')
  }

  const allowed = checkPermission(
    user.role as Role,
    user.authId,
    permission,
    resourceOwnerId
  )

  if (!allowed) {
    throw new Error(`Permission denied: ${permission}`)
  }

  return user
}

Step 4: Add Permission Context Query

Add a query to your convex/auth.ts that returns the permission context for the frontend.

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

// ... your existing auth setup ...

// Permission context query - called by usePermissions() on frontend
export const getPermissionContext = query({
  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 {
      role: user.role,
      userId: user.authId,
    }
  },
})

Step 5: Enable Permissions in Nuxt

Enable the permission composables in your module config:

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

  convex: {
    url: process.env.CONVEX_URL,
    permissions: true,  // Enable createPermissions
  },
})

Step 6: Create Frontend Composable

Use createPermissions to create your app's permission composable:

app/composables/usePermissions.ts
import { createPermissions } from '#imports'
import { api } from '~~/convex/_generated/api'
import { checkPermission, type Permission, type Role } from '~~/convex/permissions.config'

export const { usePermissions, usePermissionGuard } = createPermissions<Permission>({
  query: api.auth.getPermissionContext,
  checkPermission: (ctx, permission, resource) => {
    if (!ctx) return false
    return checkPermission(
      ctx.role as Role,
      ctx.userId,
      permission,
      resource?.ownerId
    )
  },
})

Step 7: Use Permissions in Backend

Update your Convex functions to use the authorize helper.

convex/tasks.ts
import { mutation, query } from './_generated/server'
import { v } from 'convex/values'
import { authorize, getUser } from './lib/permissions'

export const get = query({
  handler: async (ctx) => {
    // Anyone authenticated can read
    const user = await getUser(ctx)
    if (!user) return []

    const tasks = await ctx.db.query('tasks').collect()

    // Get creator names for display
    return Promise.all(
      tasks.map(async (task) => {
        const creator = await ctx.db
          .query('users')
          .withIndex('by_auth_id', (q) => q.eq('authId', task.ownerId))
          .first()
        return {
          ...task,
          creatorName: creator?.displayName ?? 'Unknown',
        }
      })
    )
  },
})

export const create = mutation({
  args: { text: v.string() },
  handler: async (ctx, args) => {
    // Check permission to create
    const user = await authorize(ctx, 'task.create')

    return ctx.db.insert('tasks', {
      text: args.text,
      isCompleted: false,
      ownerId: user.authId,  // Track owner for permission checks
    })
  },
})

export const update = mutation({
  args: { id: v.id('tasks'), text: v.string() },
  handler: async (ctx, args) => {
    const task = await ctx.db.get(args.id)
    if (!task) throw new Error('Task not found')

    // Check permission - passes ownerId for ownership check
    await authorize(ctx, 'task.update', task.ownerId)

    await ctx.db.patch(args.id, { text: args.text })
  },
})

export const remove = mutation({
  args: { id: v.id('tasks') },
  handler: async (ctx, args) => {
    const task = await ctx.db.get(args.id)
    if (!task) throw new Error('Task not found')

    await authorize(ctx, 'task.delete', task.ownerId)
    await ctx.db.delete(args.id)
  },
})

Step 8: Use Permissions in Pages

Conditional UI with can()

Use usePermissions() to show/hide UI elements based on permissions:

app/pages/tasks.vue
<script setup lang="ts">
import { api } from '~~/convex/_generated/api'
import { usePermissions } from '~/composables/usePermissions'

const { can, role } = usePermissions()
const { data: tasks } = await useConvexQuery(api.tasks.get, {}, { server: true })

const { mutate: createTask } = useConvexMutation(api.tasks.create)
const { mutate: deleteTask } = useConvexMutation(api.tasks.remove)

const newTaskText = ref('')

// can() returns a ComputedRef - Vue auto-unwraps in templates
const canCreateTask = can('task.create')
</script>

<template>
  <div>
    <p>Your role: <strong>{{ role }}</strong></p>

    <!-- Only show create form if user can create -->
    <form v-if="canCreateTask" @submit.prevent="createTask({ text: newTaskText })">
      <input v-model="newTaskText" placeholder="New task..." />
      <button type="submit">Add</button>
    </form>

    <ul>
      <li v-for="task in tasks" :key="task._id">
        {{ task.text }}
        <small>(by {{ task.creatorName }})</small>

        <!-- Pass the resource for ownership checks -->
        <button v-if="can('task.update', task).value">Edit</button>
        <button v-if="can('task.delete', task).value" @click="deleteTask({ id: task._id })">
          Delete
        </button>
      </li>
    </ul>
  </div>
</template>
Reactive by default:can() returns a ComputedRef<boolean>. Vue auto-unwraps it in templates (v-if="can('task.create')"), but in script you need .value when passing the resource (can('task.delete', task).value).

Page Protection with usePermissionGuard()

Protect entire pages from unauthorized access:

app/pages/settings.vue
<script setup lang="ts">
import { usePermissionGuard } from '~/composables/usePermissions'

// Redirects to /tasks if user can't view settings
usePermissionGuard({ permission: 'settings.view', redirectTo: '/tasks' })
</script>

<template>
  <h1>Settings</h1>
  <!-- Only admins see this page -->
</template>

Testing Permissions

The switchRole mutation below is for development and testing only. It allows users to change their own role, which is a security risk in production. The mutation includes a production safeguard that throws an error if executed in production, but you should remove this entire mutation before deploying to production.

For development, add a mutation to switch roles:

convex/tasks.ts
import { ROLES } from './permissions.config'

// DEV ONLY: Switch current user's role for testing
// ⚠️ REMOVE THIS MUTATION IN PRODUCTION - It's a security risk!
export const switchRole = mutation({
  args: { role: v.union(...ROLES.map(r => v.literal(r))) },
  handler: async (ctx, args) => {
    // Prevent execution in production
    if (process.env.NODE_ENV === 'production') {
      throw new Error('switchRole is disabled in production')
    }

    const user = await getUser(ctx)
    if (!user) throw new Error('Not authenticated')

    await ctx.db.patch(user._id, { role: args.role, updatedAt: Date.now() })
  },
})

Then add role switcher buttons to your page:

<script setup lang="ts">
import { ROLES } from '~~/convex/permissions.config'

const { mutate: switchRole } = useConvexMutation(api.tasks.switchRole)
const { role } = usePermissions()
</script>

<template>
  <div>
    Role:
    <button
      v-for="r in ROLES"
      :key="r"
      :disabled="role === r"
      @click="switchRole({ role: r })"
    >
      {{ r }}
    </button>
  </div>
</template>

Verification Checklist

Test your implementation:

  • Admin can create, edit, and delete any task
  • Member can create tasks
  • Member can edit/delete only their own tasks
  • Viewer can only see tasks (no create/edit/delete buttons)
  • Settings page redirects non-admins
  • Backend rejects unauthorized mutations with "Permission denied"

Final Structure

Here's the complete project structure after adding permissions.

convex/permissions.config.ts
// Roles from most to least powerful
export const ROLES = ['admin', 'member', 'viewer'] as const
export type Role = (typeof ROLES)[number]

// Permission rules
export const permissions = {
  'task.create': { roles: ['admin', 'member'] },
  'task.read': { roles: ['admin', 'member', 'viewer'] },
  'task.update': { own: ['member'], any: ['admin'] },
  'task.delete': { own: ['member'], any: ['admin'] },
  'settings.view': { roles: ['admin'] },
} as const

export type Permission = keyof typeof permissions

export function checkPermission(
  userRole: Role | null,
  userId: string | null,
  permission: Permission,
  resourceOwnerId?: string,
): boolean {
  if (!userRole) return false

  const rule = permissions[permission]

  if ('roles' in rule) {
    return (rule.roles as readonly string[]).includes(userRole)
  }

  if ('any' in rule && (rule.any as readonly string[]).includes(userRole)) {
    return true
  }

  if ('own' in rule && (rule.own as readonly string[]).includes(userRole)) {
    return resourceOwnerId === userId
  }

  return false
}

Next Steps

Permissions Reference

API reference for usePermissions, checkPermission, and advanced patterns.

Organization Permissions

Add multi-tenant organization support with owner, admin, member, viewer roles.

Conditional Queries

Skip queries based on permissions for better performance.