Authorization
Abilities, client-side checks, route authorization metadata, server-side requireAbility(), and the three-layer security model.
Core Guides Nuxt 4 / TypeScript
The Three-Layer Authorization Model
Authorization in this stack has three layers, and each layer stays responsible for its own decisions:
typescript
1. UI authorization auth.can("users.delete") → hides buttons, disables links
↓
2. Nuxt app/server layer route metadata + requireAbility → stops page visits and Nuxt routes
↓
3. Backend API (Laravel) policies / gates / middleware → authoritative enforcement
The lower layer is always authoritative.
auth.can() never secures an API endpoint. Route metadata never authorizes an external API call. requireAbility() never replaces a Laravel policy. Even when every Nuxt check passes, your backend must still authorize every request it receives using the bearer token.
Enabling Authorization
Authorization is optional and disabled by default. Enable it innuxt.config.ts:
typescript
export default defineNuxtConfig({
modules: ['nuxt-bearer-auth'],
bearerAuth: {
authorization: {
enabled: true,
source: 'session',
responsePaths: {
abilities: ['abilities', 'data.abilities'],
roles: ['roles', 'data.roles'],
permissions: ['permissions', 'data.permissions'],
},
rolePrefix: 'role:',
},
redirects: {
unauthorized: '/not-allowed', // where unauthorized users are sent
},
},
})
Abilities are read from session-establishing responses (login, social login, register, OTP verification) and from refresh/Direct abilities pass through as-is:
Roles are prefixed to avoid collisions: role
Permissions are treated as abilities:
Roles and permissions are input formats; abilities are what the package stores, exposes, and matches. Matching is exact string equality only — there are no wildcards (
me responses, normalized into a sorted string[], and persisted on the Redis session. An omitted field preserves existing abilities; logout and failed authentication clear them.
Abilities, Roles & Permissions
The canonical representation of authorization state is a flat array of ability strings:
"campaign.create"admin becomes "role:admin""users.delete"users.*), no prefix inheritance (users granting users.view), and no hierarchical permissions.
Client Checks: can() and cannot()
vue
<script setup lang="ts">
const auth = useBearerAuth()
</script>
<template>
<button v-if="auth.can('users.delete')" @click="destroy">Delete</button>
<p v-if="auth.cannot('campaign.create')">You cannot create campaigns.</p>
</template>
These checks are advisory UI logic only. They decide whether to render a control — they do not protect the API call behind it. A user can always craft a request directly against your backend, which is why Laravel must re-check
users.delete on DELETE /api/users/123 regardless of what the UI rendered.
UI Authorization: <Can> and <Cannot>
Two auto-imported components mirrorcan()/cannot() declaratively. Both share one pure evaluator and the same exact-match, all/any semantics as route metadata and requireAbility():
vue
<template>
<!-- single ability -->
<Can ability="users.delete">Delete</Can>
<!-- every listed ability (default mode: "all") -->
<Can :abilities="['users.view', 'users.edit']">Edit</Can>
<!-- at least one listed ability -->
<Can :abilities="['reports.view', 'reports.export']" mode="any">
Export
</Can>
<!-- inverse rendering with optional fallback -->
<Cannot ability="users.delete">
<template #fallback>Request access</template>
</Cannot>
</template>
Props and slots:
| Prop / Slot | Type | Behavior | | --- | --- | --- | |ability | string | Single required ability. Takes precedence over abilities when both are provided. |
| abilities | string[] | Multiple required abilities, combined with mode. |
| mode | 'all' | 'any' | Defaults to 'all': every listed ability must be held. 'any' requires at least one. |
| default slot | — | Rendered while the check passes (<Can>) or fails (<Cannot>). |
| #fallback | — | Optional. Rendered instead of the default slot when the check denies. |Behavior:
#fallback slot or nothing. Denied content is removed from the DOM, not CSS-hidden.abilities array — or no props at all — means "no restriction", identical to route metadata and server semantics.<Cannot> is the exact negation of the same evaluation used by <Can> — there is no second matching algorithm.auth.ready for loading UX.null, so <Can> renders fallback/nothing, identical to can().DELETE /api/users/123 — Laravel still re-checks every request it receives.
Directives such as v-can are intentionally not provided; components and can()/cannot() cover the same need with better typing and simpler SSR behavior.
Route Authorization Metadata
Any page can declare the abilities it requires viadefinePageMeta. The global bearer-auth route middleware enforces them:
vue
<script setup lang="ts">
definePageMeta({
authorization: {
abilities: ['users.view'],
mode: 'all', // optional — 'all' is the default
},
})
</script>
Runtime behavior:
1. No metadata on the route → navigation proceeds exactly as before. 2. Authorization disabled (authorization.enabled: false) → metadata is ignored.
3. Unauthenticated visitor → normal login redirect with ?redirect= preserved. Authentication failure and authorization failure stay separate.
4. Authenticated but lacking abilities → redirected to redirects.unauthorized (default /auth/not-allowed). The user is *not* sent to login — they are logged in, just not allowed.
5. Authorized → navigation continues.Route authorization protects page navigation inside your Nuxt app. It does not authorize requests that bypass routing (direct API calls), and it does not secure external APIs.
Matching Semantics: all / any / exact
| Metadata | Session abilities | Result | | --- | --- | --- | |abilities: ['users.view'] | ['users.view'] | allowed |
| abilities: ['users.view'] | ['users.edit'] | unauthorized |
| ['a', 'b'] (default all) | has both | allowed |
| ['a', 'b'] (default all) | has only one | unauthorized |
| ['a', 'b'], mode: 'any' | has at least one | allowed |
| ['a', 'b'], mode: 'any' | has neither | unauthorized |Rules:
mode defaults to 'all': every listed ability must exist.mode: 'any': at least one listed ability must exist.users.* does not satisfy users.view; holding users.view.edit does not satisfy users.view.role:admin does not implicitly grant users.delete or anything else — roles become ordinary ability strings during normalization, so only the exact strings held are ever matched.abilities prop on <Can>/<Cannot>, an empty array in route metadata, and requireAbility(event, []) all allow access without checking abilities.Server Authorization with requireAbility()
For Nitro server routes, importrequireAbility from the dedicated server-only subpath:
typescript
// server/api/admin/users.get.ts
import { requireAbility } from 'nuxt-bearer-auth/server'
export default defineEventHandler((event) => {
// Throws 401 if unauthenticated, 403 if the ability is missing.
const session = requireAbility(event, 'users.view')
// Multiple abilities - every one required by default:
requireAbility(event, ['users.view', 'users.export'])
// At least one required:
requireAbility(event, ['reports.view', 'reports.export'], 'any')
return { users: [] }
})
Key facts:
nuxt-bearer-auth/server keeps the helper out of browser bundles. Importing it from client code will break your build - that is intentional.event.context.auth, the Redis-backed server session populated by the package server middleware. It never reads useState('bearer-auth-abilities'), request headers, query parameters, or request bodies. Client-supplied ability lists cannot grant access.event.context.authorization = { abilities, source: 'session' } - normalized strings only, no tokens or session internals - and returns the authenticated session.requireAbility(event, 'campaign.delete') stops your own route from running. If that route then calls DELETE https://api.example.com/campaigns/123, Laravel must still authorize campaign.delete itself.401 vs 403
The package keeps authentication and authorization failures distinct:
| Situation | Server response | Route behavior | | --- | --- | --- | | No valid session |401 Unauthenticated | Redirect to login with ?redirect= |
| Valid session, missing ability | 403 Authorization required | Redirect to redirects.unauthorized |Error payloads contain only a status code and generic message - never tokens, session internals, or the user ability list.
Trust Boundary & Security Rules
auth.abilities) is derived convenience data for UI rendering. It is never consulted by requireAbility().event.context.authorization contains only the normalized string[] of abilities and its source.requireAbility() fail closed - it never silently grants access.