Auth Security

Permissions Reference

API reference for role-based access control with ownership rules.
New to permissions? Follow the step-by-step setup guide to build your permission system from scratch.
Security Rule: Never trust the frontend. Always enforce permissions on the backend. Frontend checks are for UX only.

Role Hierarchy

Four roles from most to least privileged:

RoleDescription
ownerFull control, billing, can invite admins
adminManage members, content moderation
memberCreate/edit own content
viewerRead-only access
const ROLES = ['owner', 'admin', 'member', 'viewer'] as const
type Role = 'owner' | 'admin' | 'member' | 'viewer'

Permission Configuration

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

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

export const permissions = {
  // Global permissions (org-level, no resource)
  global: {
    'org.settings': { roles: ['owner'] },
    'org.billing': { roles: ['owner'] },
    'org.invite': { roles: ['owner', 'admin'] },
    'org.members': { roles: ['owner', 'admin'] },
  },

  // Resource permissions
  post: {
    create: { roles: ['owner', 'admin', 'member'] },
    read: { roles: ['owner', 'admin', 'member', 'viewer'] },
    // Ownership rules: different for own vs others' posts
    update: { own: ['member'], any: ['owner', 'admin'] },
    delete: { own: ['member'], any: ['owner', 'admin'] },
    publish: { roles: ['owner', 'admin'] },
  },

  comment: {
    create: { roles: ['owner', 'admin', 'member', 'viewer'] },
    update: { own: ['viewer'], any: ['owner', 'admin'] },
    delete: { own: ['viewer'], any: ['owner', 'admin', 'member'] },
  },
} as const

Permission Types

Simple: Any user with the role can perform the action.

create: { roles: ['owner', 'admin', 'member'] }

Ownership: Different rules for own resources vs others'.

update: {
  own: ['member'],           // Members can update their own
  any: ['owner', 'admin'],   // Owners/admins can update any
}

Frontend: usePermissions

Access role and permission checking in components.

const {
  can,              // (permission, resource?) => ComputedRef<boolean>
  role,             // ComputedRef<Role | null>
  orgId,            // ComputedRef<Id | null>
  isAuthenticated,  // ComputedRef<boolean>
  isLoading,        // ComputedRef<boolean>
} = usePermissions()

The can() Function

can() returns a ComputedRef<boolean>. Vue automatically unwraps it in templates, so you can use v-if="can('post.create')" directly. The UI will reactively update if the user's permissions change.
<script setup lang="ts">
const { can, role } = usePermissions()
const { data: posts } = useConvexQuery(api.posts.list, {})
</script>

<template>
  <!-- Global permission (no resource) -->
  <button v-if="can('org.settings')">Organization Settings</button>
  <button v-if="can('org.invite')">Invite Members</button>

  <!-- Simple resource permission -->
  <button v-if="can('post.create')">New Post</button>

  <!-- Ownership permission (pass the resource) -->
  <div v-for="post in posts" :key="post._id">
    <h3>{{ post.title }}</h3>

    <!-- Member can edit own, owner/admin can edit any -->
    <button v-if="can('post.update', post)">Edit</button>

    <!-- Same for delete -->
    <button v-if="can('post.delete', post)">Delete</button>

    <!-- Only owner/admin can publish -->
    <button v-if="can('post.publish')">Publish</button>
  </div>
</template>

usePermissionGuard

Page-level protection with redirect.

<script setup lang="ts">
// Redirect if user doesn't have permission
usePermissionGuard('org.settings', '/dashboard')
</script>

Conditional Queries

Skip queries based on permissions:

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

// Only fetch if user has permission
const { data: members, status } = useConvexQuery(
  api.organizations.getMembers,
  computed(() => orgId.value && can('org.members') ? {} : 'skip')
)
</script>

<template>
  <section v-if="can('org.members')">
    <h2>Team Members</h2>
    <div v-if="status === 'pending'">Loading...</div>
    <div v-else-if="status === 'success'">
      <MemberCard v-for="m in members" :key="m._id" :member="m" />
    </div>
  </section>
</template>

Backend: authorize()

The main security gate for mutations. Use in every mutation.

convex/posts.ts
import { mutation } from './_generated/server'
import { v } from 'convex/values'
import { authorize } from './lib/permissions'

// Global permission check
export const inviteMember = mutation({
  args: { email: v.string(), role: v.string() },
  handler: async (ctx, args) => {
    const user = await authorize(ctx, 'org.invite')
    // If we get here, user is authenticated AND has permission
    // ...
  },
})

// Resource permission check
export const updatePost = mutation({
  args: { id: v.id('posts'), title: v.string() },
  handler: async (ctx, args) => {
    const post = await ctx.db.get(args.id)
    if (!post) throw new Error('Not found')

    // Checks ownership rules: member can update own, admin can update any
    const user = await authorize(ctx, 'post.update', post)
    await ctx.db.patch(args.id, { title: args.title })
  },
})

export const deletePost = mutation({
  args: { id: v.id('posts') },
  handler: async (ctx, args) => {
    const post = await ctx.db.get(args.id)
    if (!post) throw new Error('Not found')

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

authorize() does three things:

  1. Verifies user is authenticated (throws "Unauthorized")
  2. Verifies resource is in user's org (throws "Forbidden")
  3. Verifies user has permission (throws "Forbidden: permission.name")

Backend: Helper Functions

getUser() / requireUser()

import { getUser, requireUser } from './lib/permissions'

// Returns null if not authenticated
export const listPosts = query({
  handler: async (ctx) => {
    const user = await getUser(ctx)
    if (!user) return []

    return ctx.db
      .query('posts')
      .withIndex('by_organization', q =>
        q.eq('organizationId', user.organizationId)
      )
      .collect()
  },
})

// Throws if not authenticated
export const getProfile = query({
  handler: async (ctx) => {
    const user = await requireUser(ctx)
    return user
  },
})

requireSameOrg()

Type guard for organization isolation:

import { getUser, requireSameOrg } from './lib/permissions'

export const getPost = query({
  args: { id: v.id('posts') },
  handler: async (ctx, args) => {
    const user = await getUser(ctx)
    const post = await ctx.db.get(args.id)

    // Returns false if user can't access (different org)
    if (!requireSameOrg(user, post)) return null

    return post
  },
})

checkPermission()

For queries where you want to filter instead of throw:

import { getUser } from './lib/permissions'
import { checkPermission } from '../permissions.config'

export const getMembers = query({
  handler: async (ctx) => {
    const user = await getUser(ctx)
    if (!user) return []

    // Check without throwing
    const canView = checkPermission(
      { role: user.role, userId: user.authId },
      'org.members'
    )
    if (!canView) return []

    return ctx.db
      .query('users')
      .withIndex('by_organization', q =>
        q.eq('organizationId', user.organizationId)
      )
      .collect()
  },
})

API Reference

createPermissions()

Factory function to create permission composables. Auto-imported when permissions: true in module config.

import { createPermissions } from '#imports'

export const { usePermissions, usePermissionGuard } = createPermissions({
  query: api.auth.getPermissionContext,
  checkPermission,
})

Options

OptionTypeDescription
queryFunctionReference<'query'>Convex query returning permission context
checkPermission(ctx, permission, resource?) => booleanPermission checking function

usePermissions()

Returns permission state and checking function.

const { can, role, orgId, user, isAuthenticated, isLoading } = usePermissions()

Returns

PropertyTypeDescription
can(permission, resource?) => ComputedRef<boolean>Check if user has permission
roleComputedRef<string | null>Current user's role
orgIdComputedRef<string | null>Current user's organization ID
userComputedRef<PermissionContext | null>Full permission context
isAuthenticatedComputedRef<boolean>Whether user has valid context
isLoadingComputedRef<boolean>Whether context is loading

usePermissionGuard()

Protects a page with permission requirements.

usePermissionGuard({
  permission: 'org.settings',
  redirectTo: '/dashboard',
  loginPath: '/auth/signin',
  resource: post, // Optional, for ownership checks
})

Options

OptionTypeDefaultDescription
permissionstringRequiredPermission to check
redirectTostring'/'Redirect if denied
loginPathstring'/auth/signin'Redirect if not authenticated
resource{ ownerId?: string }-Resource for ownership check
Tip: You can wrap the base composables to add custom helpers like isOwner, isAdmin, or app-specific context. See the Extending with Custom Helpers section in the setup guide.

Anti-Patterns

1. Frontend-only checks

// WRONG: No backend protection
// Backend mutation:
export const deletePost = mutation({
  args: { id: v.id('posts') },
  handler: async (ctx, args) => {
    await ctx.db.delete(args.id)  // Anyone can delete!
  },
})

// RIGHT: Always authorize on backend
export const deletePost = mutation({
  args: { id: v.id('posts') },
  handler: async (ctx, args) => {
    const post = await ctx.db.get(args.id)
    await authorize(ctx, 'post.delete', post)
    await ctx.db.delete(args.id)
  },
})

2. Hardcoding roles

// WRONG: Hardcoded role checks
if (user.role === 'owner' || user.role === 'admin') {
  // can invite
}

// RIGHT: Use permission system
if (checkPermission(ctx, 'org.invite')) {
  // can invite
}

3. Missing resource for ownership

<!-- WRONG: No resource passed -->
<button v-if="can('post.update')">Edit</button>
<!-- Returns false for members! -->

<!-- RIGHT: Pass the resource -->
<button v-if="can('post.update', post)">Edit</button>
<!-- Checks if member owns this post -->

4. Forgetting org isolation

// WRONG: Returns posts from any org
export const list = query({
  handler: async (ctx) => {
    return ctx.db.query('posts').collect()
  },
})

// RIGHT: Filter by user's org
export const list = query({
  handler: async (ctx) => {
    const user = await getUser(ctx)
    if (!user) return []

    return ctx.db
      .query('posts')
      .withIndex('by_organization', q =>
        q.eq('organizationId', user.organizationId)
      )
      .collect()
  },
})