Auth Security

Permissions Setup

Complete guide to setting up role-based access control with ownership rules.
  • Owners have full control over their organization
  • Admins can manage members and content
  • Members can create and edit their own content
  • Viewers have read-only access
Time: ~20 minutes to complete all steps

What You'll Build

┌─────────────────────────────────────────────────────────────────┐
│                        Your Application                          │
├─────────────────────────────────────────────────────────────────┤
│  Frontend                         │  Backend                     │
│  ─────────────────────────────────│──────────────────────────────│
│                                   │                              │
│  usePermissions()                 │  authorize()                 │
│  ├─ can('post.create')           │  ├─ Verify authenticated     │
│  ├─ can('post.update', post)     │  ├─ Verify same org          │
│  └─ role, orgId, isAuthenticated │  └─ Verify permission        │
│                                   │                              │
│         ▲                         │         ▲                    │
│         │ uses                    │         │ uses               │
│         │                         │         │                    │
│  ┌──────┴──────────────────────────────────┴──────────┐         │
│  │           ~/convex/permissions.config.ts            │         │
│  │           (shared permission logic)                 │         │
│  └─────────────────────────────────────────────────────┘         │
└─────────────────────────────────────────────────────────────────┘

Files you'll create:

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

Prerequisites

Before starting, ensure you have:


Step 1: Update Your Schema

Add role and organization support to your schema.

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

// Role validator for type-safe roles
const roleValidator = v.union(
  v.literal('owner'),
  v.literal('admin'),
  v.literal('member'),
  v.literal('viewer'),
)

export default defineSchema({
  // Organizations table
  organizations: defineTable({
    name: v.string(),
    slug: v.string(),
    ownerId: v.string(), // authId of creator
    createdAt: v.number(),
    updatedAt: v.number(),
  })
    .index('by_slug', ['slug'])
    .index('by_owner', ['ownerId']),

  // Users table with role
  users: defineTable({
    authId: v.string(),              // From Better Auth
    displayName: v.optional(v.string()),
    email: v.optional(v.string()),
    role: roleValidator,             // User's role
    organizationId: v.optional(v.id('organizations')),
    createdAt: v.number(),
    updatedAt: v.number(),
  })
    .index('by_auth_id', ['authId'])
    .index('by_organization', ['organizationId'])
    .index('by_email', ['email']),

  // Example: Posts with ownership
  posts: defineTable({
    title: v.string(),
    content: v.string(),
    status: v.union(v.literal('draft'), v.literal('published')),
    ownerId: v.string(),             // authId of creator (for ownership checks)
    organizationId: v.id('organizations'),
    createdAt: v.number(),
    updatedAt: v.number(),
  })
    .index('by_organization', ['organizationId'])
    .index('by_owner', ['ownerId']),
})

Key points:

  • Every user has a role field
  • Resources have ownerId (for ownership checks) and organizationId (for org isolation)

Step 2: Create Permission Configuration

This file defines WHO can do WHAT. It's shared between frontend and backend.

convex/permissions.config.ts
/**
 * Permission Configuration
 *
 * Defines roles, permissions, and the shared checkPermission() function.
 * Used by both frontend (usePermissions) and backend (authorize).
 */

// ============================================
// ROLES
// ============================================

export const ROLES = ['owner', 'admin', 'member', 'viewer'] as const
export type Role = (typeof ROLES)[number]

// ============================================
// PERMISSIONS
// ============================================
// Define WHO can do WHAT.
//
// Simple format:    { roles: ['role1', 'role2'] }
// Ownership format: { own: ['role1'], any: ['role2'] }
//   - "own" = can do to resources they created
//   - "any" = can do to any resource in org

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'] },
  },

  // Post permissions
  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'] },
  },

  // Add more resources as needed...
  // comment: { ... },
  // invoice: { ... },
} as const

// ============================================
// TYPES
// ============================================

// Auto-generate permission type from config
type GlobalPermission = keyof (typeof permissions)['global']
type PostPermission = `post.${keyof (typeof permissions)['post']}`
// Add more as you add resources:
// type CommentPermission = `comment.${keyof typeof permissions['comment']}`

export type Permission = GlobalPermission | PostPermission

export interface PermissionContext {
  role: Role
  userId: string
}

export interface Resource {
  ownerId?: string
}

// ============================================
// CHECK PERMISSION
// ============================================
// Core logic used by both frontend and backend.

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

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

  // Parse resource permission: "post.update" → ["post", "update"]
  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

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

  // Ownership permission: { own: [...], any: [...] }

  // Can do to any resource?
  if ('any' in rule && (rule.any as readonly string[]).includes(ctx.role)) {
    return true
  }

  // Can do to own resources?
  if ('own' in rule && (rule.own as readonly string[]).includes(ctx.role)) {
    if (!resource) return false
    return resource.ownerId === ctx.userId
  }

  return false
}

Step 3: Create Backend Helpers

These utilities enforce permissions in your Convex functions.

convex/lib/permissions.ts
/**
 * Backend Permission Helpers
 *
 * Server-side utilities for authorization.
 * Use authorize() in every mutation!
 */

import type { QueryCtx, MutationCtx } from '../_generated/server'
import type { Id } from '../_generated/dataModel'
import {
  checkPermission,
  type Permission,
  type PermissionContext,
  type Role
} from '../permissions.config'

// ============================================
// TYPES
// ============================================

export interface AuthUser {
  _id: Id<'users'>
  authId: string
  role: Role
  organizationId: Id<'organizations'>
  displayName?: string
  email?: string
}

// ============================================
// GET USER
// ============================================
// Returns current user or null if not authenticated.

export async function getUser(
  ctx: QueryCtx | MutationCtx
): Promise<AuthUser | 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 || !user.organizationId) return null

  return user as AuthUser
}

// ============================================
// REQUIRE USER
// ============================================
// Returns user or throws if not authenticated.

export async function requireUser(
  ctx: QueryCtx | MutationCtx
): Promise<AuthUser> {
  const user = await getUser(ctx)
  if (!user) throw new Error('Unauthorized')
  return user
}

// ============================================
// AUTHORIZE
// ============================================
// The main security gate. Use in EVERY mutation.
//
// Does three things:
// 1. Verifies user is authenticated
// 2. Verifies resource is in user's org (if provided)
// 3. Verifies user has the permission
//
// Usage:
//   await authorize(ctx, 'org.invite')           // Global
//   await authorize(ctx, 'post.update', post)    // Resource

export async function authorize(
  ctx: QueryCtx | MutationCtx,
  permission: Permission,
  resource?: { ownerId?: string; organizationId?: Id<'organizations'> },
): Promise<AuthUser> {
  const user = await requireUser(ctx)

  // Check org isolation
  if (resource?.organizationId && resource.organizationId !== user.organizationId) {
    throw new Error(`Forbidden: ${permission}`)
  }

  // Check permission
  const permCtx: PermissionContext = {
    role: user.role,
    userId: user.authId,
  }

  if (!checkPermission(permCtx, permission, resource)) {
    throw new Error(`Forbidden: ${permission}`)
  }

  return user
}

// ============================================
// REQUIRE SAME ORG
// ============================================
// Type guard for org isolation in queries.

export function requireSameOrg<T extends { organizationId: Id<'organizations'> }>(
  user: AuthUser | null,
  resource: T | null,
): resource is T {
  if (!resource || !user) return false
  return resource.organizationId === user.organizationId
}

Step 4: Add Permission Context Query

Add a query to your auth file 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 the frontend.
// Returns role, userId, and orgId for permission checks.

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,
      orgId: user.organizationId ?? null,
      displayName: user.displayName,
      email: user.email,
    }
  },
})

Step 5: Enable in Nuxt Config

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:

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

// Create permission composables for your app
export const { usePermissions, usePermissionGuard } = createPermissions<
  Permission
>({
  query: api.auth.getPermissionContext,
  checkPermission,
})

Extending with Custom Helpers

You can wrap the base composable to add app-specific helpers like role checks or additional context:

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

// Create base composables from module
const { usePermissions: useBasePermissions, usePermissionGuard: basePermissionGuard } =
  createPermissions<Permission>({
    query: api.auth.getPermissionContext,
    checkPermission,
  })

// Extended composable with custom helpers
export function usePermissions() {
  const base = useBasePermissions()

  // Role check helpers
  const isOwner = computed(() => base.role.value === 'owner')
  const isAdmin = computed(() => base.role.value === 'admin')
  const canManageOrg = computed(() =>
    base.role.value === 'owner' || base.role.value === 'admin'
  )

  // App-specific context from the permission query
  const orgName = computed(() => (base.user.value as any)?.orgName ?? null)

  return {
    ...base,
    // Custom helpers
    isOwner,
    isAdmin,
    canManageOrg,
    orgName,
  }
}

// Re-export guard with custom login path
export function usePermissionGuard(
  permission: Permission,
  redirectTo: string = '/',
  resource?: Resource,
) {
  return basePermissionGuard({
    permission,
    redirectTo,
    resource,
    loginPath: '/login', // Your app's login path
  })
}

Usage

In Components

Reactive by Default: can() returns a ComputedRef<boolean> that Vue auto-unwraps in templates. This means permission checks automatically update when the user's role changes - no manual reactivity handling needed!
<script setup lang="ts">
import { usePermissions } from '~/composables/usePermissions'

const { can, role, isAuthenticated } = usePermissions()
const { data: posts } = await useConvexQuery(api.posts.list, {})
</script>

<template>
  <!-- Global permission -->
  <button v-if="can('org.settings')">Settings</button>
  <button v-if="can('org.invite')">Invite Member</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, admin can edit any -->
    <button v-if="can('post.update', post)">Edit</button>
    <button v-if="can('post.delete', post)">Delete</button>

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

Page Protection

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

// Redirect to /dashboard if user can't access settings
usePermissionGuard({
  permission: 'org.settings',
  redirectTo: '/dashboard',
})
</script>

<template>
  <h1>Organization Settings</h1>
  <!-- Only owners see this -->
</template>

In Backend Mutations

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

// Query with org isolation
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()
  },
})

// Mutation with permission check
export const create = mutation({
  args: { title: v.string(), content: v.string() },
  handler: async (ctx, args) => {
    // Verify user can create posts
    const user = await authorize(ctx, 'post.create')

    return ctx.db.insert('posts', {
      title: args.title,
      content: args.content,
      status: 'draft',
      ownerId: user.authId,
      organizationId: user.organizationId,
      createdAt: Date.now(),
      updatedAt: Date.now(),
    })
  },
})

// Mutation with ownership check
export const update = 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 org isolation + ownership rules
    await authorize(ctx, 'post.update', post)

    await ctx.db.patch(args.id, {
      title: args.title,
      updatedAt: Date.now(),
    })
  },
})

export const remove = 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)
  },
})

Verification Checklist

Test your implementation:

  • Owner can access org settings
  • Admin cannot access org settings (redirects)
  • Member can create posts
  • Viewer cannot create posts
  • Member can edit their own posts
  • Member cannot edit others' posts
  • Admin can edit any post
  • Backend rejects unauthorized mutations

Next Steps

Permissions Reference

API reference and patterns

Authentication

Auth setup and components