Protect a route
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 workspace starter, or another host app with Auth and Authorization.
- Access control, for the vocabulary: permission, role, scope, policy.
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:
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.
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)
},
})
implicitgivesreports:readto 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.grantToputs the permission into theadminrole by name. A host can also grant it inauthorization.roles:grant: ['reports:read'].*givesadminevery 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 thelabel. - After the next
nuxi prepare(orpnpm dev),reports:readis part of the generatedPermissiontype, 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.
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_scopeotherwise. allows(event, permission, record?)answerstrueorfalseinstead of throwing, for a route that changes what it returns.
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.3. Allow cookie sessions only
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.
export default defineEventHandler(async (event) => {
await authorize(event, 'reports:export') // 403 session_required for a token
// ... build the export
})
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.
export default defineNuxtConfig({
routeRules: {
'/reports/**': { permission: 'reports:read' },
'/sign-in': { guest: true },
},
})
- The keys are
permission,authenticatedandguest. The oldroles,abilityandscopeskeys 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.
// 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.
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
| Symptom | Cause | Fix |
|---|---|---|
403 forbidden for an admin | The 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 role | The 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 token | The token is not in the Authorization header. | Send Authorization: Bearer <token>. |
403 insufficient_scope | The token was minted without the permission, or before the upgrade. | Mint a token that carries it. |
403 session_required | The permission is sessionOnly and the request used a token. | Call it from the browser, signed in. |
| The build fails on a route rule | The rule uses roles, ability or scopes, or names an unknown permission. | Use permission with a registered name. |
Next steps
- HTTP routes – give the route the error shapes and responses every module uses.
- Add an admin page – put a staff page in front of a protected route.
- Migrate to the permission model – move off the old APIs.