Policies
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:
| Result | Effect |
|---|---|
false, or it throws | Denies. Any false wins over every other policy (deny wins). |
true or undefined | Keeps 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:
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: truealso 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. Therecordisundefinedthere.- 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>):
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/dbor other server or Nuxt code, because it is also bundled into the client and into resource servers. - On the server
accessis the caller's role permissions (no hook, no policies), so evaluating it never recurses. - Register the file with
authorization.policies.shared(defaultshared/policies.ts) oraddPolicies(nuxt, { path }).
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.
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.