Permissions Cookbook

Recipes for fine-grained access control, from "public blog" to "plan-gated, per-field, per-organization". The concepts behind them are in Authentication & Authorization.

Permissions Cookbook

Recipes for fine-grained access control, from "public blog" to "plan-gated, per-field, per-organization". The concepts behind them are in Authentication & Authorization.

How a request is decided

Every request passes these layers in order; each one can only narrow what the previous allowed:

#LayerDeclared withDenied as
1Operation — may this caller read / create / update / delete this resource at all?permissions401 / 403
2Tenant — only the caller's organizationmultiTenancy404
3Row visibility — which rows exist for this callerlistFilter (SQL)404
4Object — may they act on this row?objectLevel (function)403 (dropped from lists)
5Fields — which columns they see / may writefields, hiddenFieldsstripped / 403
6Server-owned columns — ids, tenant, audit, soft-deleteautomatic + protectedFieldssilently dropped

Anything not declared at layer 1 is denied. Layers 3–4 apply to every route that touches the row — get, update, delete, restore, bulk, aggregate, ?include=, M2M — not only lists.

Permission values

permissions: {
  read: true, // everyone, signed in or not
  create: ctx => !!ctx.user, // a function: sync or async
  update: 'posts:write', // callers whose ctx.permissions holds this string
  delete: ['posts:delete', 'admin'], // …any of these
  aggregate: false, // no one — not even '*'
}

'*' in ctx.permissions is a super-admin: it passes everything except an explicit false.


Recipes

Public read, signed-in write

export const commentsAuth: ResourceAuthConfig = {
  permissions: { read: true, create: ctx => !!ctx.user, update: ctx => !!ctx.user, delete: ctx => !!ctx.user },
  // …but only your own comment can be changed or removed
  objectLevel: (row, ctx) => ctx.operation === 'list' || ctx.operation === 'get' || row.authorId === ctx.user?.id,
}

Stamp authorId on create from the server, never from the body — list it in the registration's protectedFields so a client cannot forge it, and set it in a hook:

export const commentsHooks = {
  beforeCreate: (data: any, ctx: any) => ({ ...data, authorId: ctx.user.id }),
}

(or add the audit() schema preset: createdBy is stamped for you.)

Private rows: everyone sees only their own

listFilter is SQL, so pagination and total stay correct, and it applies to get/update/delete too — another user's note is a 404, not a 403:

export const notesAuth: ResourceAuthConfig = {
  permissions: { read: ctx => !!ctx.user, create: ctx => !!ctx.user, update: ctx => !!ctx.user, delete: ctx => !!ctx.user },
  listFilter: (notes, ctx) => eq(notes.ownerId, ctx.user!.id),
}

Drafts vs published

listFilter: (posts, ctx) =>
  ctx.permissions.includes('posts:write') ? undefined : eq(posts.published, true),

undefined means "no restriction".

Roles → permission strings (RBAC)

Keep roles out of resource files: map them to permission strings once, where the caller is resolved, and let resources name capabilities.

// server/autoapi-plugins.ts
const ROLE_PERMISSIONS: Record<string, string[]> = {
  admin: ['*'],
  editor: ['posts:write', 'posts:publish', 'media:write'],
  author: ['posts:write'],
}

export default [
  createBetterAuthPlugin({
    getSession: event => serverAuth().api.getSession({ headers: event.headers }),
    getPermissions: user => (user.role ?? '').split(',').flatMap((r: string) => ROLE_PERMISSIONS[r.trim()] ?? []),
  }),
]
// modules/blog/auth.ts
permissions: { read: true, create: 'posts:write', update: 'posts:write', delete: 'posts:publish' },

Roles inside an organization

With multi-tenancy the tenant layer already limits rows to the active organization; the member's role decides what they may do there:

const orgRole = (ctx: HandlerContext) => ctx.user?.orgRole as string | undefined
const atLeast = (...roles: string[]) => (ctx: HandlerContext) => roles.includes(orgRole(ctx) ?? '')

export const invoicesAuth: ResourceAuthConfig = {
  permissions: {
    read: atLeast('owner', 'admin', 'member'),
    create: atLeast('owner', 'admin'),
    update: atLeast('owner', 'admin'),
    delete: atLeast('owner'),
  },
}

How orgRole gets onto the user: Better-Auth › organization role.

Read-only and secret fields

export const usersAuth: ResourceAuthConfig = {
  permissions: { read: ctx => !!ctx.user, update: ctx => !!ctx.user },
  objectLevel: (u, ctx) => ctx.operation === 'list' || ctx.operation === 'get' || u.id === ctx.user?.id,
  fields: {
    email: { read: ctx => ctx.permissions.includes('users:admin') }, // stripped for everyone else, not filterable
    role: { write: 'users:admin' }, // anyone else setting it gets a 403
  },
}
You wantUse
Never returned, never filterable, for anyonehiddenFields: ['passwordHash'] on the registration
Returned only to some callersfields.x.read
Setting it is an error for some callersfields.x.write → 403
Setting it is ignored for everyone (server-owned)protectedFields: ['x'] on the registration

Hidden and unreadable fields cannot be used in filter, sort, groupBy or ?include= field lists either — a query on them is a 400, so values cannot be probed.

Close an operation, open one endpoint

A resource can refuse generic writes and still expose a specific action. create: false closes POST /api/orders; the named gate opens one custom endpoint:

export const ordersAuth: ResourceAuthConfig = {
  permissions: { read: ctx => !!ctx.user, create: false, update: false, delete: false },
  custom: {
    checkout: { permissions: { create: ctx => !!ctx.user } },
    refund: { permissions: { update: 'orders:refund' } },
  },
}
// server/api/orders/checkout.post.ts
export default createEndpoint({
  resource: 'orders',
  operation: 'create',
  endpointName: 'checkout',
  async handler(ctx) { /* create the order from the cart, server-side */ },
})

An operation the named gate does not declare falls back to the resource's permission — never to "allowed".

Gate by subscription plan

Load the caller's plan once per request in a context extender, then gate on it — see Billing & Plan Gating for the full setup:

permissions: {
  read: ctx => !!ctx.user,
  create: ctx => ctx.requestMeta?.billing?.features.includes('projects') ?? false,
}

Declarative policies with a permission evaluator

Functions are flexible but opaque: the client cannot see why something is blocked. A permission can instead be a plain object that a registered evaluator understands — the same object can drive the server decision, the /permissions endpoint and your UI:

// shared/policy.ts — pure, importable from server and client
export interface Policy { roles?: string[], orgRoles?: string[], feature?: string }

export function evaluatePolicy(p: Policy, facts: { roles: string[], orgRole?: string | null, features: string[] }) {
  if (p.roles && !p.roles.some(r => facts.roles.includes(r))) return false
  if (p.orgRoles && !p.orgRoles.includes(facts.orgRole ?? '')) return false
  if (p.feature && !facts.features.includes(p.feature)) return false
  return true
}

export const isPolicy = (v: unknown): v is Policy =>
  !!v && typeof v === 'object' && ['roles', 'orgRoles', 'feature'].some(k => k in (v as object))
// server/plugins/policy.ts
import { registerPermissionEvaluator } from '@websideproject/nuxt-auto-api/plugins'
import { evaluatePolicy, isPolicy } from '~~/shared/policy'

export default defineNitroPlugin(() => {
  registerPermissionEvaluator((value, ctx) => {
    if (!isPolicy(value)) return undefined // not mine — the next evaluator may handle it
    return evaluatePolicy(value, {
      roles: ctx.user?.roles ?? [],
      orgRole: ctx.user?.orgRole,
      features: ctx.requestMeta?.billing?.features ?? [],
    })
  })
})
// modules/reports/auth.ts
permissions: {
  read: { orgRoles: ['owner', 'admin', 'member'] },
  create: { orgRoles: ['owner', 'admin'], feature: 'reports' },
  aggregate: { feature: 'analytics' },
}

An object no evaluator claims is denied.

Different rules for reading and aggregating

aggregate falls back to read; declare it to make totals a paid or privileged feature:

permissions: { read: ctx => !!ctx.user, aggregate: 'reports:view' }

Trash: who may see, restore and purge

permissions: {
  update: 'posts:write',
  delete: 'posts:write',
  viewDeleted: 'posts:write', // ?includeDeleted / ?onlyDeleted
  restore: 'posts:write',
  purge: 'admin', // DELETE ?force=true — permanent
}

Unset, they fall back to update / delete / restore. See Soft Deletes.

Linking needs update on the resource and read on the related one. Tighten it per relation — keys are relation names, the :relation route segment (which is the related resource's registered name):

permissions: {
  update: 'posts:write',
  m2m: {
    requireUpdateOnRelated: ['categories'], // must be allowed to edit a category to attach it
    relations: {
      tags: { check: ({ right, user }) => right.records!.every(t => !t.locked || user?.roles?.includes('admin')) },
    },
  },
}

Tighten a module's defaults from the app

A module ships its auth.ts; an app can override individual keys from nuxt.config — strings and arrays only (config is serialized, so functions cannot travel):

// nuxt.config.ts
autoApi: {
  authorization: {
    posts: {
      permissions: { delete: 'admin' },
      custom: { export: { permissions: { read: 'admin' } } },
    },
  },
},

Checks inside custom endpoints

The resource gate decides whether the caller may use the operation; rows are still your query. Use the same helpers the generated routes use:

export default createEndpoint({
  resource: 'projects',
  operation: 'update',
  async handler(ctx) {
    const project = await findAuthorizedRow(ctx, 'projects', ctx.params.id) // tenant + listFilter + objectLevel
    await assertResourcePermission('invoices', 'create', ctx) // a different resource's rule
    if (!(await checkPermission('update', getAuthConfig(ctx, 'clients'), ctx))) { /* degrade gracefully */ }
    // …
  },
})

More in Custom Endpoints › Row access.


Reflect permissions in the UI

GET /api/permissions answers, for the current caller, what every resource allows — including function and evaluator rules, evaluated on the server:

<script setup lang="ts">
const { canCreate, canUpdate, canDelete, permissions } = usePermissions('invoices')
const canEditTotal = computed(() => permissions.value?.fields?.total?.canWrite ?? false)
</script>

<template>
  <UButton v-if="canCreate" label="New invoice" icon="i-lucide-plus" />
  <UInput v-model="form.total" :disabled="!canEditTotal" />
  <UTooltip v-if="!canDelete" text="Only owners can delete invoices">
    <UButton label="Delete" color="error" variant="soft" disabled />
  </UTooltip>
</template>

This is UX. The server enforces the same rules on every request, whatever the UI shows.

Test the rules

import { evaluatePermission } from '@websideproject/nuxt-auto-api/utils'

it('only owners delete invoices', async () => {
  const ctx = (orgRole: string) => ({ user: { id: 1, orgRole }, permissions: [] }) as any
  expect(await evaluatePermission(invoicesAuth.permissions!.delete, ctx('admin'))).toBe(false)
  expect(await evaluatePermission(invoicesAuth.permissions!.delete, ctx('owner'))).toBe(true)
})

evaluatePermission is the pipeline's own function (undeclared → false, '*' → true). For row visibility and tenancy, test through the routes — see Testing.

Need a Landing Page?

Modern landing pages with optional modules (blog, docs, forms, i18n). Let's discuss your project.

Build Your MVP

Full-stack SaaS development. Expert in database design, multi-tenancy, and scalable architecture.

Deployment Help

Dockerize your backend, set up CI/CD pipelines, deploy to Cloudflare or Hetzner. Early-stage setup.

Suggest a SaaS Tool

Missing a calculator or tool? Suggest what you'd like to see on our site.