Modules

Authorization

Policy-based authorization (abilities, route rules and <Can>) with a role column on users.

Policy-based authorization as a Nuxt module: what an already-identified user may do. Authentication stays in Auth. The design follows nuxt-authorization: the app defines policies as functions and the current user is injected into them by a resolver (one for the app, one for the server).

Requires@nuxthub/core, @frigear-nu/module-kit, @frigear-nu/auth and @frigear-nu/users; all are installed automatically.

Quick setup

pnpm add @frigear-nu/authorization
export default defineNuxtConfig({
  modules: ['@nuxthub/core', '@frigear-nu/users', '@frigear-nu/authorization'],
  routeRules: {
    '/admin/**': { roles: ['admin'] },
  },
  authorization: {
    redirectTo: '/login', // unauthenticated page visits; omit to show a 401 error
  },
})
// shared/utils/abilities.ts - auto-imported in the app and the server
export const editPost = defineAbility((user, post: Post) => user.id === post.userId || user.role === 'admin')
// server/api/posts/[id].put.ts
export default defineEventHandler(async (event) => {
  const post = await findPost(getRouterParam(event, 'id'))
  await authorize(event, editPost, post) // throws 403 when denied
})
<Can :ability="editPost" :args="[post]">
  <button>Edit</button>
</Can>
The module adds a role column to users (see Database). That only changes the generated Drizzle schema, so generate and commit a migration in your app and in every fixture that uses the module. See Module Kit.

Options

OptionTypeDefaultPurpose
abilitiesstringshared/utils/abilities.ts, else shared/abilities.tsPath to the abilities file. Needed to refer to abilities by name in route rules and page meta.
resolvers.serverstringsession or bearer userPath to a default-export (event: H3Event) => User | null replacing the server resolver.
resolvers.clientstringsession userPath to a default-export () => User | null replacing the app resolver.
redirectTostring-Redirect target for unauthenticated page navigation. Without it a 401 error is shown.

Defining abilities

defineAbility, allow and deny are auto-imported in app and server. The first argument of a policy is the resolved user, the rest are the arguments passed when checking.

// shared/utils/abilities.ts
export const isAdmin = defineAbility(user => user.role === 'admin')

export const editPost = defineAbility((user, post: Post) =>
  user.id === post.userId || user.role === 'admin' || deny({ message: 'Not your post' }))

// Let guests reach the policy (it receives `null`).
export const viewPost = defineAbility({ allowGuest: true }, (user, post: Post) => post.published || user?.id === post.userId)

Without allowGuest, guests are denied before the policy runs.

type AuthorizationResponse = { authorized: boolean, statusCode?: number, message?: string }
type AuthorizerResponse = boolean | AuthorizationResponse | Promise<boolean | AuthorizationResponse>
type Authorizer<User = any> = (user: User, ...args: any[]) => AuthorizerResponse

interface Ability<User = any> {
  allowGuest: boolean
  original: Authorizer<User>
  execute: (user: User | null, ...args: any[]) => AuthorizerResponse
}

allow() // { authorized: true }
deny({ statusCode?, message? }) // statusCode defaults to 403

Server

The server helpers take the event first and are auto-imported.

await authorize(event, editPost, post) // throws an h3 error when denied (403 by default)
if (await allows(event, isAdmin)) { /* ... */ }
if (await denies(event, isAdmin)) { /* ... */ }
const user = await resolveAuthorizationUser(event) // the configured server resolver

Roles in sessions and tokens

The default server resolver uses getAuth, so route rules and abilities apply to cookie sessions, user API tokens and OAuth access tokens alike. A bearer request loads the user from the database, so role is always current. A cookie session carries the user stored at login, so a role change reaches it after the next sign-in (or when your code calls setUserSession). The default app resolver reads the same session user.

Policy hooks of other modules (events:authorize, forms:authorize, users:admin:authorize, oauth:authorize) receive ctx.userId. Identify the caller from it, not from the session on ctx.event: the session is empty for bearer requests.

Pure helpers

@frigear-nu/authorization/utils exports framework-agnostic functions, used by the helpers above and useful in tests:

import {
  check, allows, denies, authorize, // (ability, user, ...args), no event
  matchRoute, resolveRule, evaluateRule, normalizeAuthorizeMeta,
  roleIsHigher, roleIsHigherOrEqual, canManageRole,
  AuthorizationError, createAuthorizationError, isAbility, ROUTE_RULE_KEYS,
} from '@frigear-nu/authorization/utils'

check(ability, user, ...args) // Promise<AuthorizationResponse>
matchRoute('/admin/**', '/admin/users') // true: '/x/**' prefix, '*' one segment, or an exact path

For apps with ranked roles, the role helpers take a hierarchy from highest to lowest rank; unknown roles never outrank or equal anything:

const roles = ['admin', 'manager', 'member', 'user'] as const
roleIsHigher(roles, 'manager', 'member') // true
roleIsHigherOrEqual(roles, 'member', 'member') // true
canManageRole(roles, 'manager', 'manager') // false: only strictly lower roles
canManageRole(roles, 'admin', 'admin', { equalAllowedFor: ['admin'] }) // true

App

<Can :ability="editPost" :args="[post]">
  <button>Edit</button>
  <template #fallback><span>Read only</span></template>
</Can>
<Cannot :ability="isAdmin">You are not an admin.</Cannot>

Can accepts one ability, or an array that must all pass (then args is an array of argument arrays).

const { allows, denies, authorize } = useAuthorization() // each is also auto-imported on its own
if (await allows(editPost, post)) { /* ... */ }
Client checks only improve the UI. Enforce permissions on the server with authorize(event, ...) or route rules.

Route rules

Rules are keys on Nuxt's native routeRules. Patterns follow route rules (/admin/** prefix, * one segment, or an exact path). Every matching pattern contributes and keys of more specific patterns override less specific ones. Pages are gated by a global route middleware (also during SSR); rules for /api/** are also enforced by a Nitro middleware.

export default defineNuxtConfig({
  routeRules: {
    '/admin/**': { roles: ['admin'], ssr: false },
    '/api/admin/**': { roles: ['admin'] },
    '/admin/login': { authenticated: false }, // opens a sub-route of a protected pattern
    '/billing': { ability: 'isAdmin' }, // export name from the abilities file; no arguments
    '/members/**': { authenticated: true },
    '/pricing': { guest: true },
  },
})
KeyMeaning
rolesAllowed user.role values ([] clears an inherited restriction).
abilityName of an exported ability that must allow the user.
guestLet unauthenticated visitors reach the checks (they still apply).
authenticatedtrue requires a logged-in user; false disables authorization for the path.

Guests get 401; a wrong role or a denied ability gets 403. Per page, definePageMeta replaces the matching route rules:

<script setup lang="ts">
definePageMeta({ authorize: isAdmin }) // an ability, an ability name, or { roles, ability, args, guest }
</script>
interface RouteRule {
  roles?: string[]
  ability?: string | Ability
  args?: unknown[] // page meta only
  guest?: boolean
  authenticated?: boolean
}
type AuthorizeMeta = string | Ability | RouteRule
Nuxt's app-side route rules matcher only keeps a fixed set of keys, so the module collects the authorization keys from routeRules and nitro.routeRules at build time. Rules added at runtime are not picked up.

Database

The module extends the users table through extendDatabaseTable with one column: role (text, not null, default 'user').

Copyright © 2026