Access control
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.
Which one do I use?
| You want to | Use | Read |
|---|---|---|
| Protect a route | await authorize(event, 'events:manage') | The route pattern |
| Know who is calling, without a permission | getAuth(event) or requireAuth(event) | The principal |
| Ask a yes or no, without throwing | allows(event, permission) | The route pattern |
| Refine a permission for one record (owner only) | definePolicy | Policies |
| Override the answer from your host app | authorization:before | The before hook |
| Give a role a permission | authorization.roles | Roles |
| Let a token do something | A named scope, or the permission itself | Scopes |
| Keep a permission away from tokens | sessionOnly: true on the permission | sessionOnly |
| Add permissions, roles or scopes from a module | addPermission, addRole, addScope | How modules contribute |
Terms
One meaning per word. These five are the whole vocabulary:
| Term | Meaning |
|---|---|
| Permission | A resource:action string such as events:manage, registered by a module or your host app. It is the only thing code checks. |
| Role | A named bundle of permissions in authorization.roles. A user holds several. |
| Scope | What 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). |
| Policy | Code that refines one permission against a record. It can deny, never grant. |
| Before hook | authorization: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:piiguard 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:
kind | Credential | What it may do |
|---|---|---|
session | Sealed cookie from nuxt-auth-utils | Whatever the user's roles allow |
user-token | An API token: Authorization: Bearer fnu_… (prefix configurable), created by the user | The scopes chosen at creation, frozen into the token |
oauth | An access token: Authorization: Bearer <JWT>, issued to an OAuth application | The 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.
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.
| Helper | Returns | Use it when |
|---|---|---|
authorize(event, permission, record?) | The principal, or throws | The route needs the permission. |
allows(event, permission, record?) / denies(...) | A boolean, never throws | A 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 boolean | Minting a token or consenting to a scope. |
userCan(event, userId, permission) | A boolean | Another user than the caller, such as an assignment check. Never for the caller. |
getAuth(event) | The principal or null | Sign-in is optional. Never throws. |
requireAuth(event, { sessionOnly? }) | The principal | The route needs a signed-in caller and no permission. |
| Failure | Status and code |
|---|---|
| No valid credential | 401 unauthorized |
The permission is sessionOnly and the request used a token | 403 session_required |
| The token's frozen permissions do not cover it | 403 insufficient_scope |
| Anything else that says no | 403 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:
- Unknown permission:
403 forbidden, and an error in development. A name nobody registered is a typo, not a permission. - No principal:
401 unauthorized. sessionOnlyand a bearer credential:403 session_required.- A bearer whose frozen permissions do not cover the permission:
403 insufficient_scope. - The before hook: any
deny(), or a listener that throws, denies. Otherwise anyallow()allows and skips steps 6 and 7. - Roles: the user's roles (assigned, default,
autoand fromauthorization:roles), minus those whose requirements fail, closed overinherit, plus theimplicitpermissions. A token from a foreign issuer uses its ownpermissionsclaim here instead. - Policies: only for a check with a record (or a policy marked
withoutRecord).falseor a throw denies;trueandundefinedkeep the result. - 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.
| Situation | Result |
|---|---|
@frigear-nu/authorization is not installed | Only implicit permissions apply, plus the before hook. Route rules are still enforced by Auth. |
| The permission is not registered | 403 forbidden, also for admins and also when the before hook allows. |
A before listener throws | Denies. |
| A role requirement throws | The role does not count, nor does a role that inherits it. |
| A policy throws, or a shared policy returns a promise | Denies. |
A bearer credential without a frozen permissions set | Holds nothing: 403 insufficient_scope. |
A bearer on a sessionOnly permission | 403 session_required, even for an administrator. |
* or resource:* and a sensitive permission | Not covered. Grant it by name. |
| A role in the data that is not in config | Ignored at run time, and listed in a startup warning. |
An unknown scope at /authorize | invalid_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 role | A build error. |
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.
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'] },
},
},
})
| Field | Meaning |
|---|---|
grant | Permissions or wildcards the role holds. |
inherit | Roles whose permissions this role also holds. |
requirements | name: value pairs. The role only counts while every requirement holds. A requirement is a function a module registers, such as subscription from Subscriptions. |
auto | Held by everyone who meets the requirements. Not assignable, and never in defaultRoles. |
assignable | Whether 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.
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.
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.
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
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():
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:*'] } },
})
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:
| API | What it does |
|---|---|
addPermission | Registers 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. |
addRole | Ships a default role, such as eventmanager from Events. Two modules defining one role merge grant and inherit; conflicting requirements fail the build. |
addScope | Registers a named scope. It works for API tokens and OAuth alike, so a module needs no OAuth Server dependency. |
addRoleRequirement | Registers a requirement a role can name. The file's default export is ({ event, user, value }) => boolean. |
addPolicies | Registers 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
labelanddescriptionwin; grantandinheritare the union of both;- your
requirementsandautowin; - with
replace: trueyou 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
authorize before you load anything, and again with the record afterwards.ctx.user, never from the session on
ctx.event. The session is empty for bearer requests.403 forbidden, also for administrators.
Register it with addPermission, and let the build catch the typo in route rules and page meta.<Can>, menu filtering and route rules shape the interface. The route behind
them calls authorize itself.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
- Protect a route – apply these checks to a route of your own.
- HTTP routes – follow the handler order and error shapes.
- Authorization – roles, requirements, the client composable and the roles admin.
- Access control in a module – register permissions, roles and policies from your own module.
- Migrate to the permission model – move an app from abilities, staff
guards and
<module>:authorizehooks.