Concepts

Access control

How a server route learns who is calling and decides what they may do, with one vocabulary of permissions, roles, scopes, policies and a before hook, and what fails closed.

Every server route has to know who is calling and what they may do. A browser, a script and a mobile app each prove it differently: with a cookie, an API token or an access token. After this page you can decide which permission a route needs, how a role or a token gets it, and why a check answers no.

For: app developers and module authors. Why the model looks like this is in the decision record.

Which one do I use?

You want toUseRead
Protect a routeawait authorize(event, 'events:manage')The route pattern
Know who is calling, without a permissiongetAuth(event) or requireAuth(event)The principal
Ask a yes or no, without throwingallows(event, permission)The route pattern
Refine a permission for one record (owner only)definePolicyPolicies
Override the answer from your host appauthorization:beforeThe before hook
Give a role a permissionauthorization.rolesRoles
Let a token do somethingA named scope, or the permission itselfScopes
Keep a permission away from tokenssessionOnly: true on the permissionsessionOnly
Add permissions, roles or scopes from a moduleaddPermission, addRole, addScopeHow modules contribute

Terms

One meaning per word. These five are the whole vocabulary:

TermMeaning
PermissionA resource:action string such as events:manage, registered by a module or your host app. It is the only thing code checks.
RoleA named bundle of permissions in authorization.roles. A user holds several.
ScopeWhat an API token or an OAuth access token may do: a permission, a wildcard, or a named scope that expands to permissions (tickets is tickets:read and tickets:write) or to none (openid).
PolicyCode that refines one permission against a record. It can deny, never grant.
Before hookauthorization:before, the single override your host app answers.

A few more words are used throughout:

  • Principal. Who made a request: the signed-in user, how they proved it and the permissions that proof carries. In code it is AuthPrincipal.
  • Visitor. Someone with no valid credential.
  • Session. A sealed cookie that keeps a person signed in to one host app in server mode.
  • Access token. A short-lived signed JSON Web Token (JWT) that an issuer gives an OAuth application for a signed-in user.
  • PII. Personal data. Permissions such as events:pii guard it. They are sensitive: a wildcard never covers them.
  • Alias. An old permission name that a permission was split from. A holder of the old name also holds the new one. See Aliases.

How it works

Auth answers "who is calling?" once, for every kind of credential, and owns the engine that decides. Authorization adds roles: which permissions a user holds. Auth reaches roles only through a role provider that Authorization registers, so Auth works without it.

The principal

Every request with a valid credential becomes a principal: a user, a kind and the permissions the credential carries. There are three kinds:

kindCredentialWhat it may do
sessionSealed cookie from nuxt-auth-utilsWhatever the user's roles allow
user-tokenAn API token: Authorization: Bearer fnu_… (prefix configurable), created by the userThe scopes chosen at creation, frozen into the token
oauthAn access token: Authorization: Bearer <JWT>, issued to an OAuth applicationThe scopes the user granted, frozen into the token

Sessions are for people in a browser. The cookie names the user, and every request reads that user again from the database. A role change applies to the next request, and a deleted account ends the session.

API tokens and access tokens carry a permissions claim: the scopes they were issued with, expanded and intersected with what the user could do at that moment. Every check against a bearer credential looks at that frozen set first, then at what the user holds now. So a token never does more than it was issued for, and never more than its user may do today. The frozen set is also why Auth never has to look up scope definitions while serving a request.

A request whose bearer token looks like one Auth knows (the fnu_ prefix or a JWT) but is invalid, expired or revoked is unauthenticated. This holds even when it also carries a valid session cookie.

The route pattern

authorize is the one call a route makes to decide. It returns the principal, or throws.

// server/api/events/[id].patch.ts
export default defineEventHandler(async (event) => {
  await authorize(event, 'events:manage') // 401 or 403, before anything is looked up
  const { id } = await getValidatedRouterParams(event, idParams().parse)
  const ev = await loadEvent(id) // then 404
  await authorize(event, 'events:manage', ev) // again with the record, so policies can refine
  // ...
})

The first call answers the permission as such, so a visitor learns nothing about which ids exist. The second call adds the record, which runs the policies. A route that has no record to load makes only the first call.

HelperReturnsUse it when
authorize(event, permission, record?)The principal, or throwsThe route needs the permission.
allows(event, permission, record?) / denies(...)A boolean, never throwsA menu, an MCP tool or a branch inside a route.
inspect(event, permission, record?){ allowed, status, code, via }You need to know why: via is before, role or policy.
canGrantScope(event, scope)A booleanMinting a token or consenting to a scope.
userCan(event, userId, permission)A booleanAnother user than the caller, such as an assignment check. Never for the caller.
getAuth(event)The principal or nullSign-in is optional. Never throws.
requireAuth(event, { sessionOnly? })The principalThe route needs a signed-in caller and no permission.
FailureStatus and code
No valid credential401 unauthorized
The permission is sessionOnly and the request used a token403 session_required
The token's frozen permissions do not cover it403 insufficient_scope
Anything else that says no403 forbidden

getUserSession is only for flows that are session by nature: sign-in, sign-out and OAuth consent. Use authorize, requireAuth or requireUser, never nuxt-auth-utils' requireUserSession.

How a check is decided

authorize gathers its inputs (roles, the hook, policies) and hands them to one pure function, decide. It runs these steps in order, and the first that answers wins:

  1. Unknown permission: 403 forbidden, and an error in development. A name nobody registered is a typo, not a permission.
  2. No principal: 401 unauthorized.
  3. sessionOnly and a bearer credential: 403 session_required.
  4. A bearer whose frozen permissions do not cover the permission: 403 insufficient_scope.
  5. The before hook: any deny(), or a listener that throws, denies. Otherwise any allow() allows and skips steps 6 and 7.
  6. Roles: the user's roles (assigned, default, auto and from authorization:roles), minus those whose requirements fail, closed over inherit, plus the implicit permissions. A token from a foreign issuer uses its own permissions claim here instead.
  7. Policies: only for a check with a record (or a policy marked withoutRecord). false or a throw denies; true and undefined keep the result.
  8. The result.

Steps 3 and 4 always win. Neither the before hook nor a policy can widen a credential, and a token can never use a sessionOnly permission, whatever the user's roles are.

Fail closed

When something is missing or goes wrong, the answer is no.

SituationResult
@frigear-nu/authorization is not installedOnly implicit permissions apply, plus the before hook. Route rules are still enforced by Auth.
The permission is not registered403 forbidden, also for admins and also when the before hook allows.
A before listener throwsDenies.
A role requirement throwsThe role does not count, nor does a role that inherits it.
A policy throws, or a shared policy returns a promiseDenies.
A bearer credential without a frozen permissions setHolds nothing: 403 insufficient_scope.
A bearer on a sessionOnly permission403 session_required, even for an administrator.
* or resource:* and a sensitive permissionNot covered. Grant it by name.
A role in the data that is not in configIgnored at run time, and listed in a startup warning.
An unknown scope at /authorizeinvalid_scope. A refresh silently drops what is no longer allowed.
A grant, wildcard, requirement or inherit that names nothing, an inheritance cycle, or an auto default roleA build error.
Without Authorization, a signed-in user can still use implicit permissions: the self-service ones every user needs, such as tickets:read or profile:write. Nothing else is allowed until you install it or answer the before hook.

Roles

A role is a named bundle, defined in authorization.roles and by modules, and assigned to users in the database.

nuxt.config.ts
export default defineNuxtConfig({
  authorization: {
    defaultRoles: ['user'], // every signed-in user holds these
    roles: {
      admin: {
        label: 'Admin',
        grant: ['*'], // never covers sensitive permissions: grant `forms:pii` and friends by name
      },
      manager: {
        label: 'Coordinator',
        grant: ['forms:*', 'users:read', 'users:list'],
        inherit: ['eventmanager', 'crew'],
      },
      eventmanager: { label: 'Event Manager', grant: ['events:*'], inherit: ['user'] },
      member: {
        label: 'Member',
        grant: ['member-area:view'],
        requirements: { subscription: '*' }, // counts only while every requirement holds
        auto: true, // held by everyone who meets the requirements; not assignable
        inherit: ['user'],
      },
      user: { label: 'User', grant: ['events:read'] },
    },
  },
})
FieldMeaning
grantPermissions or wildcards the role holds.
inheritRoles whose permissions this role also holds.
requirementsname: value pairs. The role only counts while every requirement holds. A requirement is a function a module registers, such as subscription from Subscriptions.
autoHeld by everyone who meets the requirements. Not assignable, and never in defaultRoles.
assignableWhether admins may assign it. Always false for auto and default roles.

A user can hold several roles, and holds the union of what they grant. Roles come from config only, so a typo cannot be introduced from a screen and every environment agrees. Only which user holds which role lives in the database (authorization_user_roles), with a source: manual, sso or migrated. Single sign-on replaces its own rows and leaves manual ones alone.

defaultRoles is your baseline for every signed-in user. implicit is different: it belongs to a permission a module declares as self-service, and applies even without Authorization.

Assigning roles is guarded. Assigning or removing a role needs roles:assign, and the actor must hold every permission the role grants (sensitive ones by name). Editing another user needs the actor to hold every permission that user holds. So a coordinator cannot make themselves an admin, or edit one. These two rules are in the code, not in policies, so no host policy can override them.

Permissions and sensitive ones

A permission is resource:action. A held * matches every permission, and resource:* every action of one resource, with one exception: sensitive permissions. :pii permissions are sensitive by default, and a module can mark others. A wildcard never covers them, so a role or a token sees other people's personal data only when it was granted by name. That is also why grant: ['*'] on an admin role does not include forms:pii.

For now the built-in sensitive permissions, events:pii and forms:pii, apply to MCP tools only: they gate personal data in MCP tool output. The admin HTTP API of the same data (attendees, submissions) is gated by events:manage and forms:manage, so a role that manages events or forms sees it in the admin pages.

Aliases

When a permission is split, the old name becomes an alias of the new ones. Holding the alias holds them all, so existing roles and tokens keep working. It works on the held side only: code that checks the old name fails, because an unregistered name is unknown.

Policies

A policy refines one permission for one record. It runs after the roles, only when the check has a record.

server/policies/events.ts
export default definePolicy('events:manage', ({ user, record }) => {
  // true or undefined keeps the role's answer, false denies
  return record.status !== 'archived' && (record.ownerId === user.id || undefined)
})

A policy returns true, false or undefined. Any false denies, and true and undefined keep the result, so policies narrow a grant and never create one. An "owner may edit" rule is record.ownerId === user.id || undefined on top of a role grant. Module policies run before host ones, and policy results are never cached.

defineSharedPolicy is the same for rules that must also run in the browser, so a button's can() agrees with the server. A shared policy is synchronous and imports nothing from #imports or @nuxthub/db; the build fails if it does.

The before hook

authorization:before is the one place your host app overrides the answer, like Laravel's Gate::before. It runs after the credential checks (steps 3 and 4), so it can never widen a token.

server/plugins/authorization.ts
export default defineNitroPlugin((nitro) => {
  nitro.hooks.hook('authorization:before', (ctx) => {
    // identify the caller from ctx.user, never from the session on ctx.event: it is empty for bearers
    if (ctx.permission.startsWith('forms:') && isOnFormsAllowlist(ctx.user)) ctx.allow()
    if (ctx.user.banned) ctx.deny()
  })
})

Results are collected across listeners: any deny() or a throw denies, otherwise any allow() allows. A listener that needs to know what the user could otherwise do must not call authorize for the same permission, which would fire the hook again.

To map an environment allowlist to roles instead, answer authorization:roles. It receives { event, user, roles }: push a role to add it. The role still has to pass its requirements.

sessionOnly

Mark a permission sessionOnly: true when it changes who the user is or how they sign in, or moves money: password, email, passkeys, API tokens, the billing portal, role assignment, refunds. A leaked API token must not be able to mint more credentials or take over the account.

sessionOnly belongs to the permission definition, never to a call. Money and credential actions get their own permissions (events:refund, users:manage), so one rule holds wherever the permission is checked: a token can never hold it and it is never offered as a scope. requireAuth(event, { sessionOnly: true }) remains for the few routes that need a signed-in session without any permission.

Scopes

A scope is what a delegated credential may do. It is one of:

  • a permission (tickets:read), or a wildcard (*, tickets:*);
  • a named scope that expands to permissions: { name: 'tickets', permissions: ['tickets:read', 'tickets:write'] };
  • an OpenID Connect scope (openid, profile, email) that expands to none.

Never a sensitive or sessionOnly permission inside a named scope. A user may only grant a scope when every permission it expands to passes the before hook and their roles (canGrantScope). For OAuth there is a further cap per role and a client check for wildcards: see Create an OAuth application.

Scopes only narrow: a token never does more than its owner can, now or when it was issued.

Where the browser gets its answer

GET /api/auth/me/access returns { kind, scopes, permissions, roles? }: the registered permissions the caller passes without a record. Authorization keeps it in useState, refreshes it on sign-in and sign-out, and serves useAuthorization() and <Can> from it, so the interface agrees with the server:

<Can permission="events:manage" :record="ev"><UButton label="Edit event" /></Can>
// nuxt.config.ts: auth enforces route rules on the server too, so they fail closed
routeRules: { '/admin/**': { permission: 'admin:access' } } // keys: permission, authenticated, guest
// a page
definePageMeta({ authorize: 'events:manage' })

In client mode (a mobile or single-page app) the same composable asks the issuer's /me/access with the bearer token, or decodes the permissions claim of the access token when the issuer has no such endpoint. It is for the interface only: the API decides.

How modules contribute

Declaring access

A module declares its permissions, named scopes and default roles in one typed file, and registers it from setup():

src/runtime/access.ts
import { defineAccess } from '@frigear-nu/auth/access'

export const reportsAccess = defineAccess('reports', {
  permissions: {
    read: { label: 'Read reports', implicit: true }, // reports:read, every signed-in user
    export: { label: 'Export reports', grantTo: ['admin'] }, // given to a role by name
    pii: { label: 'See personal data in reports' }, // sensitive by default
  },
  scopes: { reports: { label: 'Your reports', permissions: ['reports:read'] } },
  roles: { reporter: { label: 'Reporter', grant: ['reports:*'] } },
})
src/module.ts
import { addPolicies, addRoleRequirement, registerAccess } from '@frigear-nu/auth/utils'
import { reportsAccess } from './runtime/access'

registerAccess(nuxt, reportsAccess)
addRoleRequirement(nuxt, { name: 'department', path: resolver.resolve('./runtime/server/requirements/department') })
addPolicies(nuxt, { path: resolver.resolve('./runtime/server/policies/reports') })

The keys become the names (reports:read) and the i18n keys (reports.permissions.read), and the result is typed: reportsAccess.permissions.export is 'reports:export', for routes and tests. Auth also generates the Permission type from everything registered, so a misspelled permission anywhere in the app (authorize, can, route rules, defineRoles in nuxt.config.ts) fails typecheck. The full guide is Access control for module authors.

registerAccess calls these build APIs, which every module's setup() can also call directly:

APIWhat it does
addPermissionRegisters permissions (full names). The same name from two sources must agree on sensitive and sessionOnly, or the build fails. grantTo puts a permission into a role by name (a role that does not exist is skipped with a note); roles that inherit it get it too.
addRoleShips a default role, such as eventmanager from Events. Two modules defining one role merge grant and inherit; conflicting requirements fail the build.
addScopeRegisters a named scope. It works for API tokens and OAuth alike, so a module needs no OAuth Server dependency.
addRoleRequirementRegisters a requirement a role can name. The file's default export is ({ event, user, value }) => boolean.
addPoliciesRegisters a file of definePolicy and defineSharedPolicy exports.

How host config merges

When your authorization.roles.<name> matches a role a module shipped, it merges into it:

  • your label and description win;
  • grant and inherit are the union of both;
  • your requirements and auto win;
  • with replace: true you discard the module's definition entirely, including permissions other modules granted to that role by name.

Permissions your host app adds under authentication.permissions register like a module's.

The build fails on a grant that names no registered permission (a wildcard must match at least one), an unknown requirement, an inheritance cycle, a conflicting redefinition, an auto role in defaultRoles, and an unknown permission in a route rule or page meta.

Provenance

The compiled role remembers where each permission came from: the host, a module, a grantTo of another module, or inheritance from a parent role. The roles screen in the admin area shows it, so "why does this role hold this?" has a direct answer. A module can read the whole compiled model from the auth:access-compiled build hook.

Common mistakes

Answering 404 before 401 or 403. A route that loads the record first tells a visitor which ids exist. Call authorize before you load anything, and again with the record afterwards.
Reading the session in a before hook or policy. Identify the caller from ctx.user, never from the session on ctx.event. The session is empty for bearer requests.
Checking a name nobody registered. An unknown permission is always 403 forbidden, also for administrators. Register it with addPermission, and let the build catch the typo in route rules and page meta.
Hiding a button and calling it done.<Can>, menu filtering and route rules shape the interface. The route behind them calls authorize itself.
A missing module dependency. A module whose routes call authorize or requireAuth without listing @frigear-nu/auth in its moduleDependencies and package.json breaks in any app that does not install Auth some other way.

Next steps

Copyright © 2026