OAuth Server

OAuth Server hooks

Decide who may manage OAuth applications, which roles may sign in where and what goes into tokens.

OAuth Server has no role model or staff list of its own. Who may manage applications, and which roles may sign in to which application, come from the permissions and roles of Auth and Authorization. What is left for you to answer is what goes into a token, extra vetoes and audit logging. This page is for app developers, in server mode only. Set up the module first: Overview.

Hooks

Your app answers these Nitro hooks from a server plugin (server/plugins/*.ts).

HookWhen it runsWhat your listener does
oauth:application:accessAfter the allowedRoles check passed.Sets allowed = false, and optionally reason, to refuse.
oauth:claimsAn ID token, an access token or a userinfo response is built.Changes claims.
oauth:token:issuedTokens were issued, at a code exchange or a refresh.Logs or audits.
oauth:token:revokedA refresh token family was revoked.Logs or audits.
Identify the user from ctx.user or ctx.userId, never from the session on ctx.event. The session is empty for requests that carry a token, and event is missing for background calls.

Each hook receives one context object:

type ApplicationAccessContext = {
  event?: H3Event // undefined for background calls such as listUserApplications(userId)
  user: OAuthUser
  application: OAuthApplication
  roles: string[] // the user's effective roles
  allowed: boolean // true when the role check passed
  reason?: string // shown to the user as the 403 message
}

type ClaimsContext = {
  user: OAuthUser
  application: OAuthApplication
  scope: string[] // granted scopes
  roles: string[] // effective roles
  tokenType: 'id_token' | 'access_token' | 'userinfo'
  claims: Record<string, unknown> // registered claims are re-applied afterwards
}

type TokenIssuedContext = { userId: number, applicationId: string, scope: string[], grant: 'authorization_code' | 'refresh_token' }
type TokenRevokedContext = {
  applicationId: string
  userId: number | null
  reason: 'revoked' | 'reuse_detected' | 'user_grants_revoked' | 'signed_out' | 'credentials_changed'
}

type OAuthUser = { id: number, name: string, email: string, avatar: string | null } & Record<string, unknown>

Who may sign in to an application

Each OAuth application lists the roles that may sign in to it, in allowedRoles. A user may sign in when they hold at least one of them. An empty list lets every signed-in user in.

The roles are the user's effective roles: the roles assigned to them, the default and auto roles, and everything those roles inherit. Authorization resolves them, so you write no hook. A role that inherits member therefore passes an application that lists member.

nuxt.config.ts
oauthServer: {
  clients: [{
    id: 'mobile',
    name: 'Example mobile',
    redirectUris: ['com.example.app:/auth/callback'],
    allowedRoles: ['member'], // admins inherit `member`, so they pass too
  }],
}
An application with a non-empty allowedRoles locks out everybody who holds none of them. Without Authorization nobody holds any role.

To decide from other data, such as a suspended account, use the oauth:application:access hook (below). To give a user a role from data you already have, such as an email allowlist, add it through the authorization:roles hook of Authorization.

Roles are checked at the authorization request, on the consent page, on approval, at every token exchange and on userinfo. So a user who loses a role is stopped at the next refresh.

Who is an admin

The staff routes that manage applications and scopes call requireOAuthAdmin(event). It answers 401 when nobody is signed in. It answers 403 forbidden unless the user holds the oauth:manage permission, and 403 session_required for a bearer token, since the permission is sessionOnly. The admin role is given it by default. To let another role in, grant it:

nuxt.config.ts
authorization: {
  roles: {
    integrations: { label: 'Integrations', grant: ['oauth:manage'] },
  },
},

Every user also holds oauth:read, which lets them see the applications they may sign in to.

Enriching tokens and userinfo

oauth:claims runs for every token and for userinfo. Use it to add a role, a custom scope's data or a per-application value. When it runs, the module has already added the standard profile claims for the granted scopes. The permissions claim of an access token comes from the granted scopes, not from this hook.

The registered claims are applied again after your listener, so you cannot overwrite them. They are iss, sub, aud, exp, iat, nbf, jti, nonce, auth_time, scope, client_id and permissions.
export default defineNitroPlugin((nitro) => {
  nitro.hooks.hook('oauth:claims', async (ctx) => {
    // Access tokens stay bare, so only the ID token and userinfo get identity claims.
    if (ctx.tokenType === 'access_token') return

    ctx.claims['https://frigear.nu/claims/role'] = ctx.roles[0] ?? 'user'

    // A custom scope: list it in `oauthServer.scopes: ['groups']`, applications then request `scope=openid groups`
    if (ctx.scope.includes('groups')) ctx.claims.groups = ctx.roles

    // Per application
    if (ctx.application.tags.includes('shop')) ctx.claims.customer_id = await customerIdFor(ctx.user.id)

    // Remove something
    if (!ctx.scope.includes('email')) delete ctx.claims.email
  })
})

Preferences uses this hook to forward user preferences as claims.

Vetoing access and auditing

oauth:application:access runs after the allowedRoles check and can still refuse a user, for example a suspended account. Its reason is shown to the user as the 403 message. oauth:token:issued and oauth:token:revoked only report, so use them for audit logs.

export default defineNitroPlugin((nitro) => {
  // After the role check: e.g. only users with a verified email, or a suspended flag
  nitro.hooks.hook('oauth:application:access', (ctx) => {
    if (ctx.user.suspended) {
      ctx.allowed = false
      ctx.reason = 'Your account is suspended.'
    }
  })

  nitro.hooks.hook('oauth:token:issued', ({ userId, applicationId, grant }) => {
    console.info(`[oauth] tokens issued to user ${userId} for ${applicationId} (${grant})`)
  })
  nitro.hooks.hook('oauth:token:revoked', ({ applicationId, userId, reason }) => {
    console.warn(`[oauth] revoked ${applicationId}/${userId}: ${reason}`)
  })
})

Removed hooks

These hooks no longer exist. A listener for one is never called.

Removed hookUse instead
oauth:authorizeThe oauth:manage permission, granted through roles.
oauth:user:rolesRoles from Authorization. allowedRoles match the effective roles.
oauth:scope:staffGive the permission a scope stands for only to the roles that should have it. staffOnly is gone too.

Migrate to the new access control walks through the move.

Next steps

Copyright © 2026