Going further

Create an OAuth application

Register an app at an issuer that runs OAuth Server, as a confidential application in the admin area or a public one in the admin area or nuxt.config.

An app can sign its users in at your issuer only after it is registered there. That registration is an OAuth application. It gives the app a client_id, the redirect URIs the issuer accepts and, optionally, the roles that may sign in. This page registers one at an issuer that runs OAuth Server, and picks its type.

For: whoever runs the issuer, and app developers who connect an app to it.

The other side of the sign-in depends on the app:

Pick the application type

An application is confidential or public. The type is fixed when the application is created. Why the two exist is on Public and confidential applications.

Your appTypeWhy
A web app with its own server, such as Nuxt with Sign in with frigear.nuConfidentialThe secret stays on your server. It is sent on every token request, next to PKCE (Proof Key for Code Exchange).
A single-page app (SPA), a Capacitor or mobile app, a desktop app or a CLIPublicAnything shipped to a browser or a device can be extracted, so a secret there is not a secret. PKCE protects the code exchange, and refresh token rotation protects the refresh token.
Machine to machine, with no userNot supported/oauth/token takes only the authorization_code and refresh_token grants. Every token belongs to a signed-in user, and there is no client_credentials grant.
  • PKCE with S256 is mandatory for both types. A request without code_challenge_method=S256 is a 400.
  • A public application has no secret at all. The server stores a throwaway hash and never shows it.
  • A client_secret sent to a public application is refused, with 401 invalid_client. It is not ignored.
  • A confidential application without its secret gets 401 invalid_client on every token and revoke request.
  • Client mode is public by design. A production build that embeds NUXT_PUBLIC_AUTHENTICATION_CLIENT_CLIENT_SECRET fails.
  • The type cannot be changed afterwards. Register a new application instead.

Create it in the admin area

Use the admin area for a confidential application, or for a public one that does not need the same client_id in every environment.

Without these two things nobody can manage applications:
  • The admin page: oauthServer.settings.pages.admin: true, with Admin installed.
  • A user whose role holds the oauth:manage permission (admins do by default), or a before hook listener that allows it. Nobody else can manage applications. Access control fails closed.
  1. Open OAuth applications in the Settings group of the admin sidebar (/admin/oauth). Choose Add application.
  2. Pick the Client type: Confidential or Public.
  3. Fill in the Name, which the consent screen shows.
  4. Fill in the Website URL: the origin of the app, without a path, such as https://app.example.com. Use http only for localhost. A confidential application needs it. A public application needs it or at least one redirect URI, so a native app can leave it empty and list its callback under Redirect URIs.
  5. Fill in the Redirect URIs. See Redirect URIs.
  6. Optional: a Preview URL pattern, such as https://*.vercel.app. It accepts the callback path on preview deployments, where * is one hostname label.
  7. Set the Allowed roles. Leave them empty to let every signed-in user in, or list the roles that may. They are the roles of authorization.roles (including inherited and auto ones), plus any your app adds in the authorization:roles hook.
  8. Optional: Login URL, Description, Icon, Priority and Tags. The Login URL is where the Open button of the user's application list goes, and it defaults to the website URL. Priority orders that list.
  9. Choose Add application.
A modal shows the Client ID (app_…) and, for a confidential application, the Client secret. The secret is shown only this once. Store it before you close the modal. See Secrets.
What you should see: the new application in the list at /admin/oauth. Its edit form opens at /admin/oauth?application=<id>.

A row's menu has Edit application, Copy client ID, Disable application, New client secret, Sign everyone out and Delete application. Disable application sets isActive to false.

Create it in nuxt.config

oauthServer.clients defines public applications in config. These are configured clients. Use them when:

  • the app ships with its client_id compiled in, such as a mobile app, an SPA or a starter, and the id must be the same in every environment;
  • the registration should be versioned with the app.

Ids are free-form, such as mobile or com.example.app. app_… is reserved for applications created in the admin area.

nuxt.config.ts (the issuer)
export default defineNuxtConfig({
  oauthServer: {
    cors: { paths: ['/api/'] },
    clients: [
      {
        // Mobile (Capacitor): custom-scheme redirect, webview origins for CORS.
        id: 'bills-mobile',
        name: 'Bills (mobile)',
        redirectUris: ['nu.frigear.bills:/auth/callback'],
        // Accepted under `nuxt dev` only.
        devRedirectUris: ['http://localhost:3001/auth/callback'],
        corsOrigins: ['capacitor://localhost', 'https://localhost'],
      },
      {
        // Web SPA: the origin is taken from the redirect URI.
        id: 'bills-web',
        name: 'Bills (web)',
        redirectUris: ['https://bills.example.com/auth/callback'],
        devRedirectUris: ['http://localhost:3000/auth/callback'],
      },
    ],
  },
})
Config cannot hold a confidential application. type accepts only 'public', because a secret does not belong in config, and an invalid entry fails the build. Register confidential applications in the admin area, or with createApplication.
  • Other fields. websiteUrl, description, icon, allowedRoles, tags and corsOrigins. See CORS. The full table is on Configured clients.
  • Runtime override. NUXT_OAUTH_SERVER_CLIENTS, as JSON, replaces the list at runtime.
  • The stored row. The row is created on the first OAuth request, and the config wins on every read.
  • In the admin area. The application shows Managed in nuxt.config. Staff can disable it and change its priority and login URL. Any other change, or a delete, is 409 managed_by_config.
  • Turning it back. Remove the entry from config to turn it into an ordinary application.
  • Id clashes. An id that already belongs to a confidential application is ignored, with a warning in the server log.

Redirect URIs

The server accepts a redirect_uri only when it is an exact string match of one of these. No prefixes, wildcards, query strings or fragments.

  • The website URL plus callbackPath, which defaults to /auth/frigear. This applies to applications created in the admin area. Configured clients do not get it implicitly: list every URI.
  • An entry of Redirect URIs (redirectUris, at most 20). It is https, or http for localhost, 127.0.0.1 and [::1]. Loopback is matched exactly, so list the port you use.
  • The Preview URL pattern, for web redirects.

Native redirects are for public applications only. A confidential application is limited to https and loopback.

  • A native redirect is a private-use scheme with one slash, such as nu.frigear.bills:/auth/callback, with the app id in lowercase.
  • A webview origin also works, such as capacitor://localhost/auth/callback.
  • Web schemes, javascript:, data:, file:, credentials and fragments are rejected.
AppRegister
Auth client mode in a browser<origin>/auth/callback (its callbackPath default), plus http://localhost:3000/auth/callback for development
Auth client mode in a native app<native.scheme in lowercase>:/auth/callback
Sign in with frigear.nu, in Auth<origin>/auth/frigear: the website URL plus the server's default callbackPath, so no extra entry

devRedirectUris exist for configured clients only. For an application created in the admin area, add the localhost URI to Redirect URIs.

Remove a localhost redirect URI before production. Nothing marks it as development.

An access token is a scope of what the user may do, never more. Four things decide what an application gets:

LayerWhat it does
What existsOpenID Connect scopes (openid, profile, email), named scopes (oauthServer.scopes, a module's addScope, and scopes you add in the admin area), and permissions or resource:* wildcards.
What the user may grantEvery permission the scope expands to must pass the user's roles and the before hook (canGrantScope). A scope that does not is shown as unavailable on the consent screen and left out of the token.
What the role capsoauthServer.settings.roleScopes lists the most each role may grant to a client.
What the application may ask forWildcards (*, tickets:*) only when the application has wildcardScopes on.
  • Named scopes. A scope is one word (tickets) that stands for permissions. Define it in config, or add it in the admin area (Scopes, in the Settings group). A named scope never holds a sensitive or sessionOnly permission.
    nuxt.config.ts (the issuer)
    oauthServer: {
      scopes: [
        { name: 'tickets', label: 'Your tickets', permissions: ['tickets:read', 'tickets:write'] },
      ],
    }
    

    Scopes defined in config or by a module are read-only in the admin area (409 managed_by_config). When a stored scope grows, its version is bumped and users are asked to consent again.
  • Unknown scopes. A scope the server does not know is invalid_scope at /oauth/authorize, redirected back to the application once its redirect URI is trusted. On a refresh nothing is rejected: scopes the user can no longer grant are dropped silently. No scope means openid profile email.
  • The role cap. roleScopes maps a role to the scopes, permissions or wildcards it may grant. A user's cap is the union over all their roles, inheritance included, and a role that is not listed contributes nothing. Unset, there is no cap beyond the user's permissions. Names must be configured roles; the build checks them.
    nuxt.config.ts (the issuer)
    oauthServer: {
      settings: {
        roleScopes: {
          admin: ['*'],
          user: ['openid', 'profile', 'tickets:read', 'tickets:write'],
        },
      },
    }
    

    It is a ceiling, not a default: a role listed there still only grants what the user could do.
  • Wildcard scopes. An application gets * or resource:* only with wildcardScopes: true on it (default off). Sensitive permissions (:pii) are never covered by a wildcard, and sessionOnly permissions are never offered as scopes at all.
  • The permissions claim. The access token carries the scopes, expanded and intersected with the user's permissions at issue. A resource server reads this claim (OAuth Resource) instead of looking up roles it does not have. A token never gains permissions later; it only loses them if the user does.
  • Introspection. A resource server that cannot verify tokens locally calls POST /oauth/introspect (RFC 7662) with its client credentials. It authenticates as a confidential application and only gets an answer for tokens issued to it or for its audience: anything else is { "active": false }. The answer holds the live permissions, so a role change shows at once, not at expiry.
  • Consent. Every sign-in shows the consent screen (/authorize) with the requested scopes. It cannot be skipped per application.
  • Allowed roles. An application with roles refuses users who hold none of them: with 403 at the authorization request, and with access_denied at the token endpoint. Roles are resolved through the role provider, so assigned, inherited and auto roles all count. The check runs again on every refresh. So removing a role, or disabling the application, stops the next refresh.
  • Refresh tokens. The server always issues one with a user grant. There is no offline_access scope: it is dropped as unsupported, so asking for it is harmless. Each refresh replaces the token, and using an old one revokes its whole family.
  • Lifetimes. accessTokenTtl (default 900 s) and codeTtl (600 s) are server options. How long a refresh token lives is set per application (Session length in the admin area), else by the server's refreshToken option (30 days unused). The token response includes refresh_token_expires_in. See Session lifetimes.

Secrets

A confidential application's secret is shown once: in the modal after creating the application, and after New client secret. Only a hash is stored, so a lost secret is replaced, not recovered.

Store the secret in the server's runtime config, set through the environment. For Sign in with frigear.nu that is NUXT_FRIGEAR_SSO_CLIENT_SECRET. Never put it in runtimeConfig.public, a committed file, or a browser or mobile bundle.
ActionWhat it does
New client secretRotates the secret (POST /api/admin/oauth-server/applications/:id/secret). The old secret stops working at once, and every refresh token of the application is revoked, so everyone is signed out. A public application has no secret to rotate (400).
Sign everyone outRevokes all refresh tokens and keeps the secret. Use it after a leak of tokens.
Disable applicationBlocks new sign-ins and refreshes.
Delete applicationAlso removes the application's codes and tokens.

Access tokens already issued stay valid until they expire, after 15 minutes by default. To end one user's sessions, call revokeUserGrants(userId, applicationId?). See the OAuth Server helpers.

CORS for browser and Capacitor clients

Native redirects need no cross-origin requests (CORS). Two kinds of app do call the server from a browser on another origin, so CORS applies to them:

  • the webview of a Capacitor app: capacitor://localhost on iOS, and https://localhost on Android;
  • an SPA on another origin.
SettingWhat it does
oauthServer.cors.originsExact origins, never *, that may call /oauth/token, /oauth/revoke, /oauth/userinfo and the /.well-known documents. NUXT_OAUTH_SERVER_CORS_ORIGINS takes a comma separated list.
A configured client's corsOriginsThe client's own origins. By default, those of its redirect URIs that have one: a native nu.frigear.bills:/… has none. false adds none. For a mobile app, list the two webview origins here.
oauthServer.cors.pathsPath prefixes of your own API, such as ['/api/'], that the same origins may call with a bearer token.
  • An application created in the admin area adds no origins. List them in cors.origins.
  • /oauth/authorize is never opened to CORS. It is a navigation, with cookies.
  • Cookies never travel cross-origin. So session-only routes answer 401 to these apps.

Connect the app

A public application, in a browser or mobile app. Point Auth in client mode at the issuer. There is no secret option.

nuxt.config.ts (the app)
export default defineNuxtConfig({
  modules: ['@nuxt/ui', '@frigear-nu/auth'],
  authentication: {
    mode: 'client',
    client: {
      issuer: 'https://id.example.com',
      clientId: 'bills-mobile', // or app_… for an admin-created application
      scopes: ['openid', 'profile', 'email'], // must contain 'openid'
      discovery: false, // the Frigear OAuth server's own paths
      native: { scheme: 'nu.frigear.bills' }, // nu.frigear.bills:/auth/callback
    },
  },
})

A confidential application, in a web app with its own server. Use Sign in with frigear.nu (oauth.frigear), with the Client ID and secret from the modal. Create the application with the app's origin as the Website URL. <origin>/auth/frigear is then accepted without a Redirect URIs entry.

.env (the app)
NUXT_FRIGEAR_SSO_URL=https://id.example.com
NUXT_FRIGEAR_SSO_CLIENT_ID=app_…
NUXT_FRIGEAR_SSO_CLIENT_SECRET=…

With both set, GET /auth/frigear signs users in at the issuer. The options are on Auth.

A service that only receives the tokens, such as an API behind the app, is not an OAuth application. It verifies the access token with OAuth Resource, and needs no application of its own.

For other issuers, and the full checklist of what the app needs, see Use your own OAuth server.

Troubleshooting

SymptomLikely cause
401 invalid_client at /oauth/tokenA wrong client_id; a confidential application without its secret, or with an old one after rotation; or a secret sent to a public application. Remove the secret from a public application's app.
The authorize page says "The redirect_uri is not allowed for this application"Not an exact match. Compare scheme, case, port, trailing slash and path (appid:/auth/callback, one slash). A native URI on a confidential application is never accepted.
"Unknown or disabled application"A wrong client_id, or the application is disabled.
invalid_grant on a refreshThe refresh token was already used, was revoked or expired. Reuse happens when two tabs or devices share one, or a response was lost after rotation. Revocation comes from Sign everyone out or a rotated secret. The whole family is revoked: sign in again.
invalid_grant: redirect_uri does not matchThe token request's redirect_uri differs from the authorization request's, or the code was reused or expired (codeTtl).
invalid_scope at /oauth/authorizeThe request names a scope the server does not know. Define it in oauthServer.scopes or the admin area, or ask for a permission that is registered.
A scope missing from the token, or unavailable on the consent screenThe user's roles do not grant it, it lies outside their roleScopes cap, or it is a wildcard for an application without wildcardScopes.
access_denied at the token endpoint, or 403 at authorizeThe user lacks an allowed role, the application is disabled, or oauth:application:access vetoed.
409 managed_by_config in the admin areaThe application is a configured client. Edit oauthServer.clients.
500 not_configuredoauthServer.clients, or NUXT_OAUTH_SERVER_CLIENTS, is invalid. The server log names the problem.
A browser CORS error on the token request or the APIThe origin is not in cors.origins or the client's corsOrigins, or the API path is not under cors.paths. The authorization endpoint is never CORS-enabled, by design.
A confidential application in a mobile app or SPADo not. Register a public application: a secret in the bundle is public, and Auth's client mode fails the build for it.

Next steps

  • Signing keys – set up the key the issuer signs this application's tokens with.
  • Routes and clients – look up every route and configured client field.
  • Access control – how permissions, roles and scopes fit together.
  • Identity – see how the app, the issuer and the resource server relate.
Copyright © 2026