Nuxt RBAC guide: roles, route guards, admin, and audits

You cannot ship a serious SaaS without clear roles and permissions. In Nuxt, that means more than a login form. You need a clean policy model, route and API guards that hold in production, UI rules that reduce mistakes, and audits you can trust. This guide shows a practical RBAC setup in Nuxt that scales from a solo build to a multi-tenant app without turning into glue code.
If you prefer a head start, Shipahe.ad is a Nuxt SaaS starter kit with user authentication, protected pages, an admin panel, and a typed ORM. That foundation makes it straightforward to store roles and enforce permissions without rewriting auth or seeding tables by hand.
Model RBAC that fits Nuxt and your product
Keep the role set lean
Start with the smallest set that matches how people use your app. A typical SaaS can ship with: Owner, Admin, Member, and Billing Viewer. If you sell an AI tool, add a focused role like AI Editor for users allowed to run or configure generations. Fight the urge to create roles for one customer. Add a permission when behavior changes, not a new role.
Use stable permission keys
Make permission names short and explicit. Examples: account.read, account.update, billing.manage, users.invite, ai.generate, posts.publish. Do not bake user IDs or tenant IDs into keys. Keep them as pure capabilities that can be evaluated against context.
Data model that stays flexible
- roles: id, name, description
- permissions: id, key, description
- role_permissions: role_id, permission_id
- user_roles: user_id, role_id, scope_type, scope_id
The scope_type and scope_id pair lets you grant roles at tenant, project, or organization level without schema churn. With Shipahe.ad’s preconfigured database and typed ORM, you can generate these tables and seed initial roles to avoid drifting keys between environments.
Centralize policy logic
Create a small policy module with a single job: answer whether the current user can perform an action. Keep its surface tight and cache permissions per request.
// server/policy.ts export type PermissionKey = | 'account.read' | 'account.update' | 'billing.manage' | 'users.invite' | 'users.manageRoles' | 'ai.generate';export async function currentUser(event) { // Load the session and eager-load roles → permissions once per request const session = await getUserSession(event); if (!session) return null; const user = await db.user.findById(session.userId, { include: { roles: { include: { role: { include: { permissions: true } }, scope: true } } } }); // Flatten into a Set for fast checks user.permissionSet = new Set( user.roles.flatMap(r => r.role.permissions.map(p => p.key)) ); return user; }
export function can(user, key, opts = {}) { if (!user) return false; // Optional scope check if your app uses scoped roles if (opts.scopeId && opts.scopeId !== user.activeScopeId) return false; return user.permissionSet.has(key); }
export function requireCan(user, key, opts = {}) { if (!user) throw createError({ statusCode: 401, statusMessage: 'Unauthenticated' }); if (!can(user, key, opts)) { throw createError({ statusCode: 403, statusMessage: 'Forbidden' }); } }
By computing the permission set once and keeping it in memory for the request, route and API checks stay fast and consistent.
Enforce permissions on routes and server APIs
Route middleware for protected pages
Use one middleware for authentication, then a focused one for authorization. Attach required permissions via route meta so you do not scatter checks across components.
// middleware/auth.global.ts export default defineNuxtRouteMiddleware(async (to) => { const user = await useRequestFetch()('/api/me'); if (!user && to.path !== '/signin') return navigateTo('/signin'); });// middleware/authorize.ts export default defineNuxtRouteMiddleware(async (to) => { const permission = to.meta.permission as string | undefined; if (!permission) return; const { data: allowed } = await useFetch('/api/can', { query: { key: permission } }); if (!allowed.value) return abortNavigation(createError({ statusCode: 403 })); });
// pages/billing.vue <script setup lang="ts"> definePageMeta({ middleware: 'auth', 'authorize', permission: 'billing.manage' }); </script>
This keeps navigation rules declarative. Your components stay focused on rendering.
Repeat the check on the server
Anything sensitive must be enforced in server handlers under /server/api. Never rely on a client-only guard for actions like inviting users, updating billing, or changing roles.
// server/api/users/invite.post.ts export default defineEventHandler(async (event) => { const user = await currentUser(event); requireCan(user, 'users.invite', { scopeId: getTenantId(event) });
const body = await readBody(event); // ... perform invite return { ok: true }; });
Return 401 when no session is present and 403 when the user is signed in but not allowed. Log denials so you can audit them later. Handle invited-but-unverified users, suspended users, and expired subscriptions explicitly. Even if you map paid features to roles after checkout, the server should not assume the UI hid the button.
Secure UI and admin actions
Guard rendering with intent
Hide or disable UI for actions the user cannot take. It is not security by itself, but it reduces confusion and support tickets. A small composable keeps templates readable.
// composables/useCan.ts export function useCan(key: string) { const user = useState<{ permissions: Set<string> } | null>('user'); return computed(() => Boolean(user.value && user.value.permissions.has(key))); }
// In a component template // <button="!canInvite" title="You do not have permission">Invite user</button> <script setup lang="ts"> const canInvite = useCan('users.invite'); </script>
- Buttons: disable and add a short tooltip with the reason.
- Menus: hide entries for roles that can never use them.
- Forms: make fields read-only when the user has read but not update permissions.
Admin panel for role management
Extend the Admin Panel to view a user, see assigned roles, and add or remove them. Protect the screen behind a permission like users.manageRoles. Pair the UI with the server check and audit every change. For multi-tenant apps, make scope explicit when adding a role so you do not grant global access by accident.
Tie roles to monetization carefully
If you sell subscriptions, map checkout completion to a role grant, not to a plan string. Shipahe.ad supports multiple payment providers with one-time and subscription flows. After a successful checkout, assign a role such as Pro Member. Use idempotent webhooks to apply or revoke roles on payment events. Your policy can then use the role to allow premium features like higher AI generation limits while keeping the logic independent from pricing labels.
For international audiences, Shipahe.ad’s i18n support and in-app language switch keep permission-related UI clear across locales. A short, translated reason on disabled buttons lowers confusion.
Audit and test the policy
Record who changed what
Add an audit table for security-sensitive events. Store actor_user_id, target_type, target_id, action, context JSON, and timestamp. Capture role assignments, removals, failed permission checks, and admin actions like suspending accounts. Typed models let you filter and export these logs without guessing at field names.
// server/lib/audit.ts
export async function audit(event, entry) {
const actor = await currentUser(event);
await db.auditLog.create({
data: {
actorUserId: actor?.id ?? null,
targetType: entry.targetType,
targetId: entry.targetId,
action: entry.action,
context: JSON.stringify(entry.context || {}),
},
});
}
Schedule reviews and notifications
Send a daily summary of permission denials and recent role changes. Shipahe.ad includes scheduled tasks, so add one to email a digest using transactional templates. If denials spike after a release, you will see it before your inbox fills up.
Test policies, then test flows
- Unit tests: feed policy helpers a matrix of users, roles, scopes, and permissions. Assert true or false for edge cases like mixed roles across scopes.
- Integration tests: hit server APIs with seeded users and confirm 401, 403, and 200 responses.
- End-to-end tests: log in as each role and run key flows. Expect the UI to hide or disable blocked actions and the server to reject forced attempts.
Flaky tests hide real permission issues. If your E2E suite drifts, use a practical playbook on exploratory testing for Android and iOS that tracks and reduces test flakiness to stabilize results and keep your RBAC coverage trustworthy, especially if you ship a mobile companion app.
Pitfalls and where a Nuxt starter kit helps
- Putting logic in the wrong place. Keep enforcement on the server. UI checks are helpful but must mirror server rules.
- Exploding role counts. Resist one-off roles. Capture capabilities as permissions and reuse roles.
- Hardcoding checks across the app. Centralize in a policy helper and route meta. Audits and refactors stay sane.
- Skipping audits. Without logs, you cannot answer who changed a role or why someone saw a 403.
- Tying permissions to plan names. Plans change. Keep roles and permissions about capabilities, not pricing labels.
RBAC touches auth, database, server APIs, UI, email, payments, and scheduling. A good Nuxt boilerplate shortens the distance between idea and reliable enforcement. Shipahe.ad ships authentication, protected pages, an admin panel, a preconfigured database with a typed ORM, transactional email, payment flows, and cron jobs. You keep focus on the policy that matches your product instead of wiring the same plumbing again.
Key takeaways
- Define a small role set and stable permission keys. Store them in first-class tables with optional scope.
- Enforce permissions in server routes and mirror rules in route middleware and UI.
- Add audits, daily summaries, and tests so your RBAC holds up under change.
- Use a Nuxt SaaS starter kit to handle auth, admin, ORM, email, payments, and scheduling so you can ship faster.
Recommended resources
Ready to ship your SaaS?
Nuxt multi-tenant SaaS architecture patterns that scale
A practical guide to nuxt multi-tenant SaaS design. Learn viable routing, shared-DB isolation, billing, and testing patterns that scale without surprises.
Nuxt SaaS case study: launching an MVP in 10 days
How we shipped a paid AI MVP in 10 days with a Nuxt starter kit. Real metrics on traffic, signups, conversions, revenue, and the exact features we used.