Auth

Auth server API

The server helpers that authorize your routes, define permissions, roles and scopes, and handle tokens and auth emails.

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.

CallBehaviour
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
}
Whether a permission is 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.

Use 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:action string. Permissions and sensitive ones explains wildcards and :pii permissions, 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/scopes and POST /api/auth/me/tokens use canGrantScope: every permission a scope expands to has to pass the before hook and the user's roles (no policies), and sessionOnly and 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.

A listener must identify the caller from 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 },
  },
})
  • permission runs authorize(event, permission), so tokens, sessionOnly and the before hook apply as in a handler. An unknown permission fails the build.
  • authenticated: true needs any valid credential; authenticated: false turns 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 with 400.
  • Only pages and /api/** are enforced. Guard other routes (/mcp, /oauth/**) in their handler with authorize.
  • Rules are coarse. A handler still calls authorize for 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 Authorization header only. Tokens in the query string are ignored.
  • Tokens can expire (expiresInDays, or the tokenTtlDays option) and can be revoked.
  • lastUsedAt is 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_scope for a name nobody registered, and 403 insufficient_permission for a scope the user may not give away (canGrantScope);
  • stores the permissions the 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 or sessionOnly permissions: 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.

Changing the prefix later invalidates every token already issued. Auth no longer claims them, so they are ignored like any unknown bearer token.
export default defineNuxtConfig({
  authentication: {
    apiTokens: { prefix: 'acme_' }, // or `false` to turn API tokens off
  },
})

With apiTokens: false:

  • the /api/auth/me/tokens routes 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 aud does 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.

src/runtime/access.ts
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'] } },
})
src/module.ts
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
CallWhat 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, setAuthUserStoreBearer-token resolvers and the user store, as below.
  • implicit is for self-service permissions every signed-in user needs even without Authorization, such as tickets:read or profile:write. The host's baseline role is defaultRoles, not implicit.
  • grantTo puts 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 with replace: true drops these grants.
  • aliases are 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 auto role in defaultRoles, 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:

HookPayload
auth:resolversAuthResolverDefinition[] (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.
Copyright © 2026