Ultimate guide·

Nuxt RBAC guide: roles, protected pages, and admin controls

Practical Nuxt RBAC: model roles and permissions, guard routes and APIs, add admin guardrails and audits, and handle billing, auth, and export edge cases.

You cannot fake access control. If your Nuxt app handles paid tiers, partner portals, or a back office, one missing permission check can expose data or hurt revenue. The goal is simple: one model for who can do what, enforced the same way on pages, APIs, and jobs. Here is a practical RBAC setup for Nuxt that holds up under growth, audits, and messy real-world behavior.

Model roles and permissions

Good RBAC starts with a small role set and an explicit permission catalog. Keep it boring and readable. If a teammate cannot reason about it in a code review, it will drift.

Choose clear role names

  • Owner. Top-level authority within a workspace or account. Can change billing and assign roles.
  • Admin. Manages users and settings but cannot change ownership.
  • Member or Staff. Uses core features. Limited settings access.
  • Viewer or Read-only. Can view data but not update or export.
  • Guest. Minimal access for invitations or trials.

Keep role names neutral. Actions belong in permissions, not labels.

Write the permission catalog once

List the verbs that matter to your product, then pair them with the nouns in your domain. Typical verbs: read, create, update, delete, export, bill, invite, manage_roles. Pair with resources like project, dataset, invoice, report, model, file. This table is your source of truth for enforcement and tests.

Represent it in code and the database

Store the catalog in the database so you can adjust without redeploys. Mirror it in TypeScript for type safety. One workable approach with Prisma:

// prisma/schema.prisma
model Role { id String @id @default(cuid()) name String @unique description String? scope Scope userRoles UserRole[] rolePerms RolePermission[] }
model Permission { id String @id @default(cuid()) action Action resource Resource rolePerms RolePermission[] }
model RolePermission { roleId String permissionId String role Role @relation(fields: [roleId], references: [id]) permission Permission @relation(fields: [permissionId], references: [id]) @@id([roleId, permissionId]) }
model UserRole { userId String roleId String scopeId String? // workspace/account id role Role @relation(fields: [roleId], references: [id]) @@id([userId, roleId, scopeId]) }

enum Scope { GLOBAL WORKSPACE } enum Action { READ CREATE UPDATE DELETE EXPORT BILL INVITE MANAGE_ROLES } enum Resource { PROJECT DATASET INVOICE REPORT MODEL FILE USER SETTINGS }

Seed the base catalog once. Add a migration when you introduce a new feature that needs a permission. My rule of thumb: if you add an admin toggle for something, add a permission for it too.

Enforce RBAC in Nuxt

RBAC is only as strong as the checks on pages, APIs, and components. Apply the same decision function everywhere.

A small, shared decision helper

Put a single can() helper in a composable so templates, middleware, and server code call the same logic:

// composables/useRBAC.ts
import { useAuth } from '#imports'
export type Action = 'READ'|'CREATE'|'UPDATE'|'DELETE'|'EXPORT'|'BILL'|'INVITE'|'MANAGE_ROLES'
export type Resource = 'PROJECT'|'DATASET'|'INVOICE'|'REPORT'|'MODEL'|'FILE'|'USER'|'SETTINGS'

export function useRBAC() { const { user } = useAuth() // user roles preloaded on login const roles = computed(() => user.value?.roles || ) function can(action: Action, resource: Resource, scopeId?: string) { // Check bans/suspensions first if (user.value?.status === 'BANNED') return false return roles.value.some(r => ( (!scopeId || r.scopeId === scopeId || r.scope === 'GLOBAL') && r.permissions.some(p => p.action === action && p.resource === resource) )) } return { can } }

Cache role membership in memory per request on the server so repeated checks are cheap, but always verify on the server before reading or writing sensitive data.

Route meta and global middleware

Declare required permissions on pages and enforce them in a single middleware:

// pages/projects/[id]/edit.vue
<script setup lang="ts">
definePageMeta({ requires: [{ action: 'UPDATE', resource: 'PROJECT' }] })
</script>
// middleware/rbac.global.ts
export default defineNuxtRouteMiddleware(async (to) => {
  const { status } = useAuth()
  if (status.value !== 'authenticated') return navigateTo('/login?next=' + to.fullPath)
  const { can } = useRBAC()
  const reqs = (to.meta.requires as any[]) || []
  const scopeId = to.params.workspaceId as string | undefined
  const allowed = reqs.every(req => can(req.action, req.resource, scopeId))
  if (!allowed) return navigateTo('/error/forbidden')
})

On SSR pages, this avoids flashing restricted content. Keep the meta terse and the middleware boring.

Server validation every time

Never rely on client checks. Re-evaluate permissions in every server handler that touches sensitive data:

// server/api/projects/[id].put.ts
export default defineEventHandler(async (event) => {
  const user = await requireUser(event) // throws if not logged in
  const body = await readBody(event)
  const workspaceId = await getWorkspaceIdForProject(event.context.params!.id)
  if (!canServer(user, 'UPDATE', 'PROJECT', workspaceId)) {
    throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
  }
  return updateProject(event.context.params!.id, body)
})

For file storage, check permission before generating signed URLs, and scope keys by tenant, for example workspaces/{id}/files/{uuid}.

Component guards without UI leaks

Hide or disable affordances that would lead to denied actions, and keep templates readable:

<template>
  <UButton :disabled="!can('EXPORT','REPORT', workspaceId)" @click="exportReport">Export CSV</UButton>
  <RouterLink v-if="can('UPDATE','PROJECT', workspaceId)" :to="editUrl">Edit Project</RouterLink>
</template>
<script setup lang="ts">
const { can } = useRBAC()
const workspaceId = useWorkspaceId()
</script>

Fetch role membership early in your auth flow so first paint matches the final state.

Admin operations and auditability

Human oversight is part of access control. You need a safe way to assign and revoke roles, respond to abuse, and explain what happened later.

Assign and revoke with guardrails

In the admin area, show current roles, the scope they apply to, and a change history. Add confirmation for risky actions like removing the last Owner from a workspace. Two simple protections stop most 2 a.m. tickets:

  • Block self-demotion if it would leave no Owner.
  • Enforce “at least one Owner” with a transaction that checks count before removal.

Handle bans and suspensions

Mix account state with RBAC. A banned user should be denied everywhere regardless of roles. Implement a fast pre-check in can() and mirror it on the server.

Log what matters, not everything

Log role changes, denied access, and sensitive operations like exports, deletes, billing changes, and permission edits. Keep payloads small and reference by ID to avoid personal data in logs. A compact event shape works well:

{
  type: 'role.removed',
  actorId: 'usr_... ',
  targetUserId: 'usr_... ',
  role: 'ADMIN',
  scopeId: 'ws_... ',
  reason: 'requested by owner',
  ts: 1713811200
}

Schedule a daily report of RBAC changes. The simplest path is a cron job that hits a Nuxt endpoint that aggregates yesterday’s events and emails a summary to your security or ops channel.

Edge cases you must design for

Most RBAC bugs hide in edges. Decide these now so they do not turn into breaches or churn.

Multi-tenant scope is not optional

Tie user roles to a scope like workspace_id. A user can be an Admin in one workspace and a Viewer in another. In server queries, always filter by both the tenant and the user’s scoped role. If you use Postgres, row-level security is worth the setup so you cannot forget the WHERE clause.

Payments drive access

Subscriptions often map to feature permissions. When a payment succeeds, grant or update the role; when a subscription ends, downgrade gracefully. Use one webhook handler that translates provider events into role changes and audit events. Make it idempotent with an events table so retries do not double-apply.

// server/api/webhooks/billing.post.ts
export default defineEventHandler(async (event) => {
  const payload = await readRawBody(event)
  const evt = verifySignature(payload, getHeader(event,'stripe-signature'))
  if (seen(evt.id)) return 'ok'
  const { userId, workspaceId, tier } = mapBillingEvent(evt)
  await applyRoleForTier({ userId, workspaceId, tier })
  await logEvent({ type: 'billing.role_sync', userId, workspaceId, tier })
  markSeen(evt.id)
  return 'ok'
})

Add a short grace period to handle provider delays so upgrades feel instant and downgrades are polite.

Authentication quirks

  • Confirm email before granting roles to invited users.
  • Invalidate old sessions after role changes so access shrinks immediately.
  • Make magic links single-use and short-lived to block replay.
  • On social logins, link to existing accounts by verified email to avoid parallel accounts with different roles.

Files and exports

Exports and file downloads are sensitive. Check export permission when a job is created and again when the file is downloaded. Scope storage keys per-tenant and expire signed URLs quickly. Tools that process media for users, like SubtitlesFast, often gate higher-quality exports behind paid roles. Your Nuxt RBAC should mirror that pattern at both the job start and the download step.

Owner lockout and recovery

Protect the Owner role from accidental removal. Keep at least one Owner per workspace and add a recovery path through support that can restore ownership after proof of control. Document the process so support staff do not improvise access changes.

If you want this all working on day one, start with a Nuxt/Vue boilerplate that already ships authentication, protected pages, admin, analytics, i18n, payments, email templates, and deployment tooling. Then your job is to fill the permission catalog and wire the checks, not reinvent sessions, webhooks, and cron wiring.

Key takeaways

  • Keep a single permission catalog stored in the database and mirrored in TypeScript for compile-time checks.
  • Use route meta and global middleware to guard pages, and re-check permissions on every server handler.
  • Manage roles in an admin area with guardrails, bans, and clear change history.
  • Log role changes, denials, and sensitive operations; review daily with a simple cron-triggered report.
  • Design for edges: tenant scope, billing-driven access, auth quirks, export checks, and owner recovery.

With a solid RBAC foundation, your Nuxt app grows cleanly, your team moves faster, and customers trust the boundaries you set. Whether you roll your own or start from a Nuxt SaaS boilerplate, keep the model simple, the checks consistent, and the audits boring.

FAQ

What is RBAC in a Nuxt app?

RBAC assigns roles to users and grants permissions based on those roles. In Nuxt, you enforce it with route middleware, server checks, and an admin workflow.

Should I store roles in the JWT or in the database?

Store roles and permissions in the database as the source of truth. You can cache role IDs in a session token, but always verify on the server before allowing access.

How do I connect subscriptions to RBAC?

Map each plan to a role or set of permissions. On payment events, update the user’s role and log the change. On cancellation, downgrade after a brief grace period.

How can I test RBAC in Nuxt?

Write unit tests for your can(action, resource) helper, middleware tests for protected routes, and integration tests for server endpoints that read or write sensitive data.

What is the safest way to handle owner transfers?

Require confirmation from both parties, prevent removing the last owner, and record an audit entry. Provide a support-backed recovery path for edge cases.

Ready to ship your SaaS?

Everything you need is already built. Start today.
See Demo