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 in nuxt.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/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:

  • Direct abilities pass through as-is: "campaign.create"
  • Roles are prefixed to avoid collisions: role admin becomes "role:admin"
  • Permissions are treated as abilities: "users.delete"
  • 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 (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 mirror can()/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:

  • Authorized renders the default slot; unauthorized renders the optional #fallback slot or nothing. Denied content is removed from the DOM, not CSS-hidden.
  • An empty 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.
  • Matching is exact string equality. Malformed props or malformed client authorization state fail closed; null or missing ability state can never satisfy a non-empty requirement.
  • Evaluation reacts to login, refresh, logout, and session replacement through the existing client authorization state. No second state store exists and nothing is fetched.
  • Server-rendered HTML matches hydration because evaluation is synchronous over the SSR-transferred state. In SPA-only rendering, guarded components briefly show the denied branch until the initial auth check resolves — pair with auth.ready for loading UX.
  • Disabled authorization has no special UI mode: extraction never runs, state stays null, so <Can> renders fallback/nothing, identical to can().
  • UI authorization controls rendering only. It never secures API requests. Your backend must authorize every request it receives. Hiding a button does not authorize 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 via definePageMeta. 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.
  • Matching is exact. Holding users.* does not satisfy users.view; holding users.view.edit does not satisfy users.view.
  • Holding 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.
  • An empty requirement means unrestricted everywhere: an empty 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, import requireAbility 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:

  • Server-only export. nuxt-bearer-auth/server keeps the helper out of browser bundles. Importing it from client code will break your build - that is intentional.
  • Trusted source only. It reads abilities exclusively from 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.
  • Separate context. On success it sets event.context.authorization = { abilities, source: 'session' } - normalized strings only, no tokens or session internals - and returns the authenticated session.
  • Fail-safe. If the session has no abilities (for example because authorization is disabled), every requirement fails closed with 403.
  • Not backend enforcement. Guarding a route with 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

  • Bearer and refresh tokens remain server-side in Redis at all times; authorization features never expose them.
  • Client authorization state (auth.abilities) is derived convenience data for UI rendering. It is never consulted by requireAbility().
  • No request input (headers, query, body, cookies beyond the http-only session id) can grant abilities.
  • event.context.authorization contains only the normalized string[] of abilities and its source.
  • Disabling authorization makes route metadata inert and makes requireAbility() fail closed - it never silently grants access.
  • Your Laravel (or any) backend remains the authoritative security boundary for its own endpoints.