Tasks

Protect a route

Declare a permission with defineAccess, check it on a server route with authorize, keep money and credential actions to cookie sessions, and guard pages with route rules.

What you'll build: a reports:read permission held by a role, a server route that checks it, a second route that only a browser session may call, and a route rule for a page. Any other caller is refused with the right status.

Prerequisites

The paths below are for the starter's apps/web. In another host app, leave out the apps/web/ prefix.

1. Register the permission

Code checks one kind of thing: a registered permission, named resource:action. An unregistered name is refused with 403 forbidden, also for admins, so register it first. Declare it with defineAccess in a file the server can import too:

apps/web/shared/access/reports.ts
import { defineAccess } from '@frigear-nu/auth/access'

export const reportsAccess = defineAccess('reports', {
  permissions: {
    read: { label: 'Read reports', grantTo: ['admin'], implicit: true }, // reports:read
    manage: { label: 'Manage all reports', grantTo: ['admin'] }, // reports:manage
    // Money and credentials: a cookie session only, never a token.
    export: { label: 'Export reports', sessionOnly: true, grantTo: ['admin'] }, // reports:export
  },
})

A host app has no module of its own, so register it from a local one. Nuxt registers every file in apps/web/modules/ as a module.

apps/web/modules/reports.ts
import { defineNuxtModule } from 'nuxt/kit'
import { registerAccess } from '@frigear-nu/auth/utils'
import { reportsAccess } from '../shared/access/reports'

export default defineNuxtModule({
  meta: { name: 'reports' },
  setup(_options, nuxt) {
    registerAccess(nuxt, reportsAccess)
  },
})
  • implicit gives reports:read to every signed-in user (self-service); the policy in step 5 then limits it to the user's own reports. Leave it out for a staff-only permission.
  • grantTo puts the permission into the admin role by name. A host can also grant it in authorization.roles: grant: ['reports:read'].
  • * gives admin every non-sensitive permission anyway. Permissions that expose other people's personal data end in :pii, are sensitive by default, and are granted by name only.
  • The labels' i18n keys are derived: reports.permissions.read. Add them to your locale files, or keep the label.
  • After the next nuxi prepare (or pnpm dev), reports:read is part of the generated Permission type, so a typo in any check below fails typecheck.
  • A module declares its permissions the same way, in src/runtime/access.ts. See Access control for module authors.

2. Check it on a route

Every route follows the same two steps. Check the permission before you load anything, so a missing record never answers 404 to someone who may not know it exists. When the check is about one record, check again with the record, so policies can refine it.

apps/web/server/api/reports/[id].get.ts
export default defineEventHandler(async (event) => {
  await authorize(event, 'reports:read') // 401 or 403 before anything else
  const { id } = await getValidatedRouterParams(event, intIdParams().parse)
  const report = await loadReport(id) // then 404
  await authorize(event, 'reports:read', report) // record-level policies
  return toReportDto(report)
})

authorize is auto-imported in server code. loadReport, toReportDto and intIdParams stand for your own functions and the module-kit helper. The string is typed: authorize(event, 'reports:raed') fails typecheck. You can also name it through the declaration, authorize(event, reportsAccess.permissions.read), so renaming it is one edit.

  • It returns the principal: the user and how they proved who they are. Use it when you need the user: const { user } = await authorize(event, 'reports:read').
  • It accepts a cookie session, an API token and an OAuth access token. A token needs the permission frozen into it when it was issued: 403 insufficient_scope otherwise.
  • allows(event, permission, record?) answers true or false instead of throwing, for a route that changes what it returns.
Do not use nuxt-auth-utils' requireUserSession, and do not pass scopes to requireAuth: it no longer takes them. requireAuth(event) only answers who is calling, for a route no permission describes.

Anything that moves money, or changes who someone is or how they sign in, gets its own sessionOnly permission. The permission carries the rule, not the route, so no call can forget it.

apps/web/server/api/reports/export.post.ts
export default defineEventHandler(async (event) => {
  await authorize(event, 'reports:export') // 403 session_required for a token
  // ... build the export
})
A sessionOnly permission is never held by an API token or an OAuth token, and is never offered as a scope, even when a role grants it. For credentials, billing and account changes, see sessionOnly.

4. Guard a page with a route rule

A route rule names the permission a path needs. Auth's server middleware enforces it for /api/**, and Authorization's page middleware for pages. It is a coarse first gate: keep the authorize call in the handler.

apps/web/nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/reports/**': { permission: 'reports:read' },
    '/sign-in': { guest: true },
  },
})
  • The keys are permission, authenticated and guest. The old roles, ability and scopes keys fail the build.
  • A page can set its own rule: definePageMeta({ authorize: 'reports:read' }).
  • Hide what a visitor cannot use with <Can permission="reports:export">…</Can>, and <Cannot> for the opposite. The API decides what is allowed; the component only decides what is shown.

5. Add your own rule

A permission plus a role says what a user may do in general. A record rule narrows it. Put it in a policy: it can deny, never grant. A policy that returns false denies, so a rule that should only restrict some callers returns undefined for the rest.

apps/web/server/policies.ts
// definePolicy is auto-imported in server code. Export each policy, or an array of them.
// Only the owner may read a report, unless the caller can manage them all (`reports:manage`, checked in the policy).
export const reportOwner = definePolicy<{ ownerId: number }>('reports:read', async ({ event, user, record }) =>
  record.ownerId === user.id || await allows(event, 'reports:manage'))

Returning true or undefined keeps the role's answer, false (or a throw) denies. The host's server/policies.ts is picked up when it exists. For a rule that must also run in the browser's can(), see shared policies.

What you should see: signed in as an admin, GET /api/reports/1 answers the report. Signed out it answers 401. Signed in as a plain user it answers the user's own report and 403 forbidden for someone else's. With an API token created without reports:read it answers 403 insufficient_scope, and POST /api/reports/export with any token answers 403 session_required.

Troubleshooting

SymptomCauseFix
403 forbidden for an adminThe permission is not registered (a typo, or the module is not installed).Register it with registerAccess (or addPermission). In development the server also logs an error, and pnpm typecheck flags the typo.
403 forbidden for a user with the right roleThe role does not hold it, or a policy denied the record. * does not cover :pii permissions.Add the permission to the role's grant, or grantTo.
401 with an API tokenThe token is not in the Authorization header.Send Authorization: Bearer <token>.
403 insufficient_scopeThe token was minted without the permission, or before the upgrade.Mint a token that carries it.
403 session_requiredThe permission is sessionOnly and the request used a token.Call it from the browser, signed in.
The build fails on a route ruleThe rule uses roles, ability or scopes, or names an unknown permission.Use permission with a registered name.

Next steps

Copyright © 2026