Authorization

Policies

Refine one permission against a record, such as "only the author may edit", on the server and in the interface.

A role says "coordinators may update events". A policy says "but only events of their own region". It is code that refines one permission for one record. This page shows how to write server and shared policies, what they can and cannot do, and the two-step route that runs them. Set up the module first: Overview.

Narrowing, never granting

A policy only refines a result. By the time it runs, the credential, the before hook and the roles already allowed the permission, so granted is always true. A policy returns:

ResultEffect
false, or it throwsDenies. Any false wins over every other policy (deny wins).
true or undefinedKeeps the current result. true does not override another policy's false.

So a policy cannot turn a denial into an allow. It also never runs when the roles do not hold the permission, or after authorization:before has allowed the request. Policies run in order: modules first, then your app.

An "owner may edit" rule is therefore built on a permission everyone who should be able to try already holds (a default role or an implicit permission), and the policy removes everyone else:

server/policies.ts
export default [
  // `posts:update` is granted to every user; only the author, or someone who also holds `posts:moderate`, passes.
  definePolicy<Post>('posts:update', async ({ event, user, record }) => {
    if (record!.authorId === Number(user.id)) return undefined // keep: they hold the permission
    return await allows(event, 'posts:moderate') ? undefined : false
  }),
]

Or the other way round, on top of a role grant: record.ownerId === user.id || undefined keeps the result for the owner, and, for everyone else, leaves it to the role. Pick the shape where the role grant already matches who may try.

Server policies

definePolicy is auto-imported in server code. Export the policies from server/policies.ts (the default location), or register another file with the authorization.policies.server option. A module registers its own file with addPolicies(nuxt, { path }).

definePolicy<R>(permission, fn, { withoutRecord?: boolean })

interface PolicyContext<R> {
  event: H3Event
  user: AuthUser
  principal: AuthPrincipal // kind, scopes, frozen permissions
  record: R | undefined
  granted: boolean // always true when a policy runs
}
// fn returns boolean | undefined, or a promise of it
  • Async is fine. A server policy may query the database. A thrown error is logged and denies.
  • A record is required by default. A policy only runs for a check with a record (authorize(event, p, record)). A check without one skips it, so the first step of a route is cheap and never loads data.
  • withoutRecord: true also runs it for checks without a record. Use it for rules that do not depend on one, such as a tenant or a suspended account. The record is undefined there.
  • A policy of an unregistered permission never runs. In development the server logs an error naming it.
  • Policy results are never cached, a decision without a record is cached per request.

A policy sees principal, so it can treat API tokens differently. It cannot widen what the token's own permissions allow: that check comes first.

Shared policies

The client's can() is synchronous and has no database, so a rule the interface should know about is written once as a shared policy. It runs on the server (inside every authorize) and in the browser (inside can() and <Can>):

shared/policies.ts
import { defineSharedPolicy } from '@frigear-nu/auth/policies'

export default [
  defineSharedPolicy<Post>('posts:update', ({ user, access, record }) => {
    if (!record) return undefined
    return record.authorId === user?.id || access?.permissions.includes('posts:moderate') ? undefined : false
  }),
]
interface SharedPolicyContext<R> {
  user: User | null
  access: AccessDto | null // what the caller may do: the same shape on the server and the client
  record: R | undefined
  granted: boolean
}
  • Synchronous only. A shared policy that returns a promise counts as an error, which denies.
  • Framework free. Import only from @frigear-nu/auth/policies. The build fails when the file imports #imports, @nuxthub/db or other server or Nuxt code, because it is also bundled into the client and into resource servers.
  • On the server access is the caller's role permissions (no hook, no policies), so evaluating it never recurses.
  • Register the file with authorization.policies.shared (default shared/policies.ts) or addPolicies(nuxt, { path }).
A shared policy shapes the interface. The server runs it too, but it only has what access and user hold. Anything that needs the database belongs in a server policy.

The two-step route

A handler authorizes twice: once without the record, so a visitor or a user without the permission is turned away before anything is loaded, and once with the record, so the policies decide.

server/api/posts/[id].put.ts
export default defineEventHandler(async (event) => {
  await authorize(event, 'posts:update') // 401 or 403: nothing loaded, nothing leaked
  const { id } = await getValidatedRouterParams(event, intIdParams().parse)
  const post = await findPost(id) // 404 only for someone who may try
  await authorize(event, 'posts:update', post) // the policies run against this record
  const body = await readValidatedBody(event, updatePost.parse)
  return toPostDto(await savePost(post, body))
})

Never answer 404 before 401 or 403, and never skip the second call because "the first one passed": the first call runs no record policy. For lists, filter with allows(event, permission, record) per row, or in the query; and use inspect when the reason matters (via: 'before' | 'role' | 'policy').

null counts as no record, like undefined.

Policies and tokens

Policies run for every credential kind, with a record. They do not run for token minting and OAuth consent (canGrantScope), which decide on permissions alone: a policy cannot be frozen into a token.

Next steps

  • Client – can(permission, record) and <Can :record>.
  • HTTP routes – the handler order the two-step route follows.
  • Access control – where policies sit in the order of a check.
Copyright © 2026