Four roles from most to least privileged:
| Role | Description |
|---|---|
owner | Full control, billing, can invite admins |
admin | Manage members, content moderation |
member | Create/edit own content |
viewer | Read-only access |
const ROLES = ['owner', 'admin', 'member', 'viewer'] as const
type Role = 'owner' | 'admin' | 'member' | 'viewer'
Create a shared permission config used by both frontend and backend.
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
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
}
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()
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>
Page-level protection with redirect.
<script setup lang="ts">
// Redirect if user doesn't have permission
usePermissionGuard('org.settings', '/dashboard')
</script>
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>
The main security gate for mutations. Use in every mutation.
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:
"Unauthorized")"Forbidden")"Forbidden: permission.name")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
},
})
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
},
})
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()
},
})
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,
})
| Option | Type | Description |
|---|---|---|
query | FunctionReference<'query'> | Convex query returning permission context |
checkPermission | (ctx, permission, resource?) => boolean | Permission checking function |
Returns permission state and checking function.
const { can, role, orgId, user, isAuthenticated, isLoading } = usePermissions()
| Property | Type | Description |
|---|---|---|
can | (permission, resource?) => ComputedRef<boolean> | Check if user has permission |
role | ComputedRef<string | null> | Current user's role |
orgId | ComputedRef<string | null> | Current user's organization ID |
user | ComputedRef<PermissionContext | null> | Full permission context |
isAuthenticated | ComputedRef<boolean> | Whether user has valid context |
isLoading | ComputedRef<boolean> | Whether context is loading |
Protects a page with permission requirements.
usePermissionGuard({
permission: 'org.settings',
redirectTo: '/dashboard',
loginPath: '/auth/signin',
resource: post, // Optional, for ownership checks
})
| Option | Type | Default | Description |
|---|---|---|---|
permission | string | Required | Permission to check |
redirectTo | string | '/' | Redirect if denied |
loginPath | string | '/auth/signin' | Redirect if not authenticated |
resource | { ownerId?: string } | - | Resource for ownership check |
isOwner, isAdmin, or app-specific context. See the Extending with Custom Helpers section in the setup guide.// 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)
},
})
// WRONG: Hardcoded role checks
if (user.role === 'owner' || user.role === 'admin') {
// can invite
}
// RIGHT: Use permission system
if (checkPermission(ctx, 'org.invite')) {
// can invite
}
<!-- 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 -->
// 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()
},
})