Auth server API
This page shows how a server route learns who is calling and what they may do. It is for app developers who write routes and for module authors. Server mode only. Set up the module first: Overview.
An app in client mode has no server of its own. The API it calls protects its routes the way this page shows. That API accepts the app's access token through OAuth Server or OAuth Resource.
Protect a route
Call authorize with the permission the route needs. It is auto-imported in server code, accepts cookie sessions,
API tokens and access tokens alike, and returns the principal:
// server/api/events/[id].put.ts
export default defineEventHandler(async (event) => {
await authorize(event, 'events:update') // 401 or 403 before anything is loaded
const { id } = await getValidatedRouterParams(event, intIdParams().parse)
const ev = await findEvent(id) // 404 only now
const { user } = await authorize(event, 'events:update', ev) // the policies of the permission see the record
return updateEvent(ev, user)
})
This is the two-step route: the first call has no record, so a 404 can never answer before a 401 or 403; the
second runs the policies that refine the permission for this record.
| Call | Behaviour |
|---|---|
authorize(event, permission, record?) | Returns the principal, or throws 401 unauthorized, 403 session_required, 403 insufficient_scope or 403 forbidden. |
allows(event, permission, record?) / denies(...) | The same decision as a boolean. Never throws for a denial. |
inspect(event, permission, record?) | { allowed, status, code, via }, where via says which step decided (registry, credential, before, role, policy). |
requireAuth(event, { sessionOnly? }) | Authentication only: the principal from any credential, 401 without one. Checks no permission. |
getAuth(event) | The principal or null. Never throws. |
canGrantScope(event, scope, options?) | Whether the caller may give scope to a token or an OAuth client. See Tokens. |
userCan(event, userId, permission) | Whether another user holds a permission: their roles and the before hook, no policies. For assignment and target checks only, never for the caller. |
getAccess(event) | The AccessDto behind GET /api/auth/me/access, or null. |
getEffectiveRoles(event, user) | The roles the user holds now: assigned, default and auto roles, minus failed requirements, closed over inherit. |
definePolicy(permission, fn, { withoutRecord? }) | Defines a server policy. |
The Permission type
Every permission parameter above (and defineSharedPolicy, useAuthorization().can, <Can>, route rules,
definePageMeta({ authorize }), MCP definitions and menu, widget and tab items) is typed Permission. Auth generates
.nuxt/types/auth-access.d.ts from the registered permissions and roles, so in an app Permission is the union of
every registered name and a typo fails typecheck in the app, the server and nuxt.config.ts:
await authorize(event, 'events:mange')
// error: Argument of type '"events:mange"' is not assignable to parameter of type 'Permission'. Did you mean '"events:manage"'?
Where nothing is generated (a module package's own build, or before the first nuxi prepare), Permission is
string. A name built at runtime needs a cast (name as Permission): the build still rejects unknown permissions in
route rules and page meta, and a check of an unknown name is 403 forbidden.
interface AccessDto {
kind: 'session' | 'user-token' | 'oauth'
scopes: string[] // the credential's scopes; a session has none
permissions: string[] // registered permissions a check without a record allows
roles?: string[] // for a session, or a token with the `profile` scope
}
sessionOnly comes from its definition, never from the call. Money and credential actions get
their own sessionOnly permissions (payments:refund, users:manage), so a handler cannot forget the option. Use
requireAuth(event, { sessionOnly: true }) only for a route that checks no permission of its own.The three kinds of credential, and what the principal holds for each, are explained in
The principal, and the order in which a check is decided in
How a check is decided. Users
builds requireUser(event) on top of requireAuth; it returns just the user.
authorize, not requireAuth, for anything that is not "any signed-in user". A route that only calls
requireAuth lets every signed-in user, and every token, through.A role change applies on the next request: a session and a bearer request both load the user and their roles again.
A bad token is never ignored. A request may carry a token that a resolver claims: the API token prefix (fnu_… by
default) or a JWT. When that token is invalid, expired or revoked, the request is unauthenticated, even with a valid
session cookie. The answer is 401 with WWW-Authenticate: Bearer error="invalid_token". Tokens no resolver claims,
such as the per-form ffk_ tokens, are ignored, and the other credentials apply.
Other helpers
mapUserToSession(storedUser) // strips the password hash and applies `session.mapper`; use it to refresh the session
startAuthSession(event, storedUser) // signs the user in: a new session stamped with their `sessionVersion` and `signedInAt`
endAuthSessions(userId, event?) // ends every session of the user, except the one of `event` (a "sign out everywhere")
getLoginPath(event) // the sign-in page path (`pages.login`, else `/login`)
deleteUserAuthData(userId) // removes tokens, kept OAuth tokens, passkeys, reset codes and sign-in links (call when deleting a user)
deleteUnusedAuthEmailCodes(userId) // removes unused reset codes and sign-in links (call when the user's email changes)
Define permissions, roles and scopes
A module declares what it offers with defineAccess (@frigear-nu/auth/access) in a src/runtime/access.ts, and
registers it in setup() with registerAccess (see Extend Auth at build time and
Access control for module authors). Everything merges with the host app's
config at modules:done, and the build stops when something does not add up.
- A permission is a
resource:actionstring. Permissions and sensitive ones explains wildcards and:piipermissions, which a wildcard never covers. - A scope limits what a token may do. A scope is either a permission, a wildcard, or a named scope that expands to permissions. Scopes has the rules.
- A user is only offered the scopes they may give away. The token page,
GET /api/auth/scopesandPOST /api/auth/me/tokensusecanGrantScope: every permission a scope expands to has to pass the before hook and the user's roles (no policies), andsessionOnlyand sensitive permissions are never part of a wildcard.
The permissions each built-in module registers are on its own page, in a Permissions table: Events, Forms, Payments, Subscriptions, Users, OAuth Server, Authorization and Admin.
Answer with the before hook
A module never decides who is staff. Roles do, and the one override your host app has is
the authorization:before Nitro hook. The before hook shows how
your app answers it:
// server/plugins/access.ts
export default defineNitroPlugin((nitro) => {
nitro.hooks.hook('authorization:before', (ctx) => {
if (ctx.permission === 'events:update' && ctx.user.suspended) ctx.deny()
})
})
Listeners get { event, principal, user, permission, record, allow(), deny() }. Results are collected across
listeners: any deny(), or a listener that throws, denies; otherwise any allow() allows. A bearer request still has
to pass the credential's own limits first, so the hook can never widen a token or use a sessionOnly permission.
ctx.user or ctx.principal, never from the session on ctx.event. The
session is empty for bearer requests.The second hook is authorization:roles, { event, user, roles }, from Authorization.
It lets the host add or remove roles for a user, such as mapping an allowlist of emails to admin.
Route rules
permission, authenticated and guest are keys of Nuxt's routeRules. Auth's server middleware enforces them for
/api/** with or without Authorization, so they fail closed:
export default defineNuxtConfig({
routeRules: {
'/api/admin/**': { permission: 'admin:access' }, // 401 for visitors, 403 without the permission
'/api/reports/**': { authenticated: true }, // any credential
'/api/public/**': { guest: true },
},
})
permissionrunsauthorize(event, permission), so tokens,sessionOnlyand the before hook apply as in a handler. An unknown permission fails the build.authenticated: trueneeds any valid credential;authenticated: falseturns the rules of less specific patterns off for that path.- The match uses the percent-decoded path the router dispatches on, and an API path with an encoded
/, a\or a dot segment is refused with400. - Only pages and
/api/**are enforced. Guard other routes (/mcp,/oauth/**) in their handler withauthorize. - Rules are coarse. A handler still calls
authorizefor its own permission and its records. See also Route rules for pages.
GET /api/auth/me/access
Answers what the caller may do, for any credential: the AccessDto above, with Cache-Control: private, no-store. It
is what useAuthorization() reads in the browser, and what a mobile app or another service calls with its bearer
token. permissions is every registered permission where a check without a record passes, so a policy that needs a
record is not reflected here. roles appears for a session, and for a token that carries the profile scope.
User API tokens
Users create API tokens (fnu_…) on the account page (/account/api-tokens, see
Account) or through the API. A script or an MCP client sends one like this:
curl -H "Authorization: Bearer fnu_…" https://example.com/api/tickets
- Only a hash (SHA-256) of a token is stored, plus a prefix and the last four characters.
- A lost token is replaced, not looked up.
- Send a token in the
Authorizationheader only. Tokens in the query string are ignored. - Tokens can expire (
expiresInDays, or thetokenTtlDaysoption) and can be revoked. lastUsedAtis written at most every five minutes. A failure to write it never fails a request.
Tokens start with fnu_ unless your app picks its own prefix. A prefix holds letters, digits, _ and -.
What a token may do is frozen when it is created. POST /api/auth/me/tokens (session only):
- accepts one to 50 scopes, each a permission, a
resource:*or*wildcard, or a named scope; - answers
400 unknown_scopefor a name nobody registered, and403 insufficient_permissionfor a scope the user may not give away (canGrantScope); - stores the
permissionsthe scopes expand to, intersected with what the user holds at that moment. The wildcard*therefore stands for "everything I may delegate now", and never covers sensitive orsessionOnlypermissions: choose a sensitive one by name.
On each request the token is checked against that frozen set (step 4 of How a check is decided), and then, like a session, against the user's roles as they are now. So a token never does more than its user could when it was created, and loses what the user loses.
export default defineNuxtConfig({
authentication: {
apiTokens: { prefix: 'acme_' }, // or `false` to turn API tokens off
},
})
With apiTokens: false:
- the
/api/auth/me/tokensroutes and the token resolver are not registered, - Account drops its API tokens page,
- access tokens are not affected.
The auth_user_tokens table stays, so turning tokens back on needs no migration.
OAuth access tokens
With OAuth Server, an OAuth application the user signed in
to calls your API as that user. principal.user is the user, and principal.client.id is the application.
const principal = await authorize(event, 'tickets:read')
if (principal.kind === 'oauth') console.log(`called by application ${principal.client.id}`)
The access token carries a frozen permissions claim, like an API token's row: the scopes the user granted, expanded
and intersected with what the user held at that moment. Auth checks that claim and never asks the OAuth server about
scopes. OAuth Server explains which scopes exist and who may grant them.
Every request also checks the token against live state:
- the application must be active,
- the user must exist,
- the user must still pass the application's role requirement.
So disabling an application or changing a role cuts access immediately.
- ID tokens are not credentials. Only access tokens (
typ: at+jwt) authenticate. - The audience is the application, so
auddoes not have to match this server. - Verification checks the signature against the JSON Web Key Set (JWKS), the issuer and the type.
An MCP client that sends Authorization: Bearer … only needs an API token (MCP).
Clients that need the full OAuth flow also need protected-resource metadata
(RFC 9728) and dynamic client registration. This module provides neither.
Customize the auth emails
Auth sends the emailed sign-in link, the address verification and the password reset as EmailNotifications through
Notify. Configure the delivery driver under the notify key.
With Email Templates installed, the emails are rendered from the module's Vue
templates: auth-magic-link, auth-verify-email and auth-password-reset. Override one by adding
app/emails/<name>.vue.
Without templates, brand the sign-in link and verification emails in the auth:email hook by replacing the subject and
body. Delete ctx.notification.template there if a template should not render over your body:
export default defineNitroPlugin((nitro) => {
nitro.hooks.hook('auth:email', (ctx) => {
// ctx.type: 'magic-link' | 'verify-email'; ctx.data: { name, url }
ctx.notification.subject = 'Velkommen til Frigear'
ctx.notification.body = renderMyTemplate(ctx.data)
})
})
Extend Auth at build time
This section is for module authors. A module's setup() can import these from @frigear-nu/auth/utils. They are
callable from any module, and everything merges with the host config at modules:done.
Declare permissions, scopes and roles with defineAccess and register them with registerAccess: it derives the
names and i18n keys, and gives typed constants to use in routes and tests.
import { defineAccess } from '@frigear-nu/auth/access'
export const reportsAccess = defineAccess('reports', {
permissions: {
read: { label: 'Read reports', grantTo: ['user'] }, // reports:read, labelKey reports.permissions.read
pii: { label: 'See personal data in reports' }, // `:pii` is sensitive by default
export: { label: 'Export reports', sessionOnly: true },
},
scopes: { reports: { label: 'Your reports', permissions: ['reports:read'] } },
roles: { analyst: { label: 'Analyst', grant: ['reports:*'], inherit: ['user'] } },
})
registerAccess(nuxt, reportsAccess) // or { roles: false }, { scopes: [...] }, { permissions: [...] }, { from }
Under the hood, and for anything defineAccess does not cover (scopes read from options, a fixture's one-off role),
the low-level calls:
addPermission(nuxt,
{ name: 'reports:read', label: 'Read reports', group: 'reports', grantTo: ['user'] },
{ name: 'reports:pii', label: 'See personal data in reports' }, // `:pii` is sensitive by default
{ name: 'reports:export', label: 'Export reports', sessionOnly: true },
{ name: 'tickets:read', implicit: true, aliases: ['tickets:view'] },
)
addScope(nuxt, { name: 'reports', label: 'Your reports', permissions: ['reports:read'] })
addRole(nuxt, { name: 'analyst', label: 'Analyst', grant: ['reports:*'], inherit: ['user'], from: '@acme/reports' })
addRoleRequirement(nuxt, { name: 'subscription', path: resolver.resolve('./runtime/server/requirements/subscription') })
addPolicies(nuxt, { path: resolver.resolve('./runtime/server/policies') })
addAuthResolver(nuxt, {
name: 'my-tokens',
prefix: 'mt_', // or `jwt: true`; one of the two is required
resolver: resolver.resolve('./runtime/server/resolvers/my-token'),
order: 100, // lower runs first
})
setAuthUserStore(nuxt, resolver.resolve('./runtime/server/user-store')) // first registration wins
| Call | What it registers |
|---|---|
addPermission(nuxt, ...defs) | { name, label?, labelKey?, description?, group?, sensitive?, sessionOnly?, implicit?, aliases?, grantTo? }. The same name from two sources must agree on sensitive, sessionOnly and implicit. |
addScope(nuxt, ...defs) | A named scope: { name, label?, description?, icon?, service?, permissions? }. It works for API tokens and OAuth alike. Never lists sensitive or sessionOnly permissions. |
addRole(nuxt, ...roles) | A role the module ships. Two modules defining one role union grant and inherit; conflicting requirements fail the build. The host merges into it. |
addRoleRequirement(nuxt, { name, path }) | A requirement for a role: the file's default export is (ctx: { event, user, value }) => boolean | Promise<boolean>. A throw counts as not met. |
addPolicies(nuxt, { path }) | A file of definePolicy exports. |
setRoleProvider(nuxt, { path }) | Where assigned roles are stored; Authorization sets it. First registration wins. |
setDefaultRoles(nuxt, roles) | The roles every signed-in user holds; Authorization sets it from defaultRoles. |
addAuthResolver, setAuthUserStore | Bearer-token resolvers and the user store, as below. |
implicitis for self-service permissions every signed-in user needs even without Authorization, such astickets:readorprofile:write. The host's baseline role isdefaultRoles, notimplicit.grantToputs the permission into roles by name (['admin']), and roles that inherit them get it too. A role that is not configured is skipped, with an info log; a host role defined withreplace: truedrops these grants.aliasesare old names that, when held, also hold the new permission. They expand on the held side only.- The build fails on a grant that names no registered permission, an unknown requirement, an inheritance cycle, an
autorole indefaultRoles, a conflicting redefinition, and an unknown permission in route rules or page meta.
The Authorization pages explain how roles merge and where the provenance of each grant is shown. Writing a module is the checklist for a new module.
A resolver turns a token into a principal:
// resolver: return a principal, or null when the token is invalid
export default async function (event: H3Event, token: string): Promise<AuthPrincipal | null> { … }
The user store is how Auth reads and writes users. Users registers one:
interface AuthUserStore {
findById(id: string | number): Promise<AuthStoredUser | null>
findByEmail(email: string): Promise<AuthStoredUser | null>
create(values: AuthUserCreate): Promise<AuthStoredUser>
update(id: number, values: AuthUserUpdate): Promise<AuthStoredUser | null>
remove(id: number): Promise<void>
/** Gives the user a new id, taking everything that references it along. */
changeId(from: number, to: number): Promise<void>
}
The resolver and user store helpers are also Nuxt build hooks:
| Hook | Payload |
|---|---|
auth:resolvers | AuthResolverDefinition[] (push to register) |
auth:user-store | { path?: string } (set path) |
The utils also export parseScopeString, USER_TOKEN_PREFIX, generateUserToken, hashToken, parseBearer,
looksLikeJwt and tokenStatus, and the pure engine (decide, matches, holds, compileRoles) for tests.
Next steps
- Protect a route – authorize a route of your own, with a record.
- HTTP routes – the handler order and error shapes for module routes.
- Reference – every route, hook and table of Auth.