Auth

Auth client mode

Sign people in from an app without its own server, such as a browser SPA or a Capacitor app, and call your API as them.

Client mode signs people in from an app without its own server, such as a browser single-page application (SPA) or a Capacitor app: at an issuer, with PKCE (Proof Key for Code Exchange), and with tokens the app keeps and refreshes itself. This page is for app developers; client mode only, with the same app code and Authorization guards as server mode.

ServerClientMobile
Installed for you:@frigear-nu/module-kit. Client mode installs none of server mode's dependencies (NuxtHub, nuxt-auth-utils, Notify, Menu).You add: in a Capacitor app, @capacitor/app, @capacitor/browser, @capacitor/preferences and @aparajita/capacitor-secure-storage.Optional:@nuxt/ui, for the sign-in and callback pages and AuthSignInButton.
For a server-rendered web app, keep server mode. Client mode moves the credential from an httpOnly cookie into localStorage (or the Keychain on a device). There, any cross-site scripting (XSS) can read it in a browser, and the server cannot know who is signed in. See Security.

Quick setup

Install the modules

pnpm add @frigear-nu/auth @frigear-nu/authorization
# inside a Capacitor app also:
pnpm add @capacitor/app @capacitor/browser @capacitor/preferences @aparajita/capacitor-secure-storage

Configure client mode

Set the mode, the issuer and your app's client id. The route rule signs people in everywhere: the sign-in and callback pages stay open without a rule, and a visitor is sent to the sign-in page.

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/ui', '@frigear-nu/auth', '@frigear-nu/authorization'],
  authentication: {
    mode: 'client',
    client: {
      issuer: 'https://frigear.nu',
      clientId: 'my-app',
      scopes: ['openid', 'profile', 'email', 'offline_access'], // must contain 'openid'
      apiBase: 'https://api.example.com', // default: the issuer
      native: { scheme: 'com.example.app' }, // redirect URI com.example.app:/auth/callback
    },
  },
  routeRules: {
    '/**': { authenticated: true },
  },
})

Register the app at the issuer

Sign-in fails unless the redirect URIs match exactly and the issuer allows your origins.

Register a public OAuth application at the issuer with these redirect URIs:

  • browser: <origin>/auth/callback, for example http://localhost:3000/auth/callback;
  • native: <scheme in lowercase>:/auth/callback, for example com.example.app:/auth/callback.

Any OpenID Connect provider works as the issuer. It must also allow the app's origins for CORS on the token, revoke and userinfo endpoints. These are capacitor://localhost, https://localhost and your dev origin. The full list is in Use your own OAuth server.

Call your API

app/pages/bills.vue
<script setup lang="ts">
const { user } = useUserSession()
const { logout } = useAuth()
const { data: bills } = await useApi<Bill[]>('/api/bills')
</script>
What you should see: opening the app sends you to /login. After you sign in at the issuer, you come back to the page you opened, and useUserSession().user holds your name and email.

The mode

Client mode checks the app when it builds:

CheckWhat happensWhy
Server-side rendering (SSR) is offClient mode sets ssr: false. ssr: true in the app or a layer fails the build.A server render would always be signed out.
openid is requiredScopes without openid fail the build.The signed-in user comes from the ID token.
No server modulesnuxt-auth-utils and Users fail the build, naming the module that installed them.They need a server and a database.
Guards need AuthorizationrouteRules with authenticated, permission or guest but no Authorization print a warning.Nothing would check them.

The openid check also covers runtimeConfig.public.authentication.client.scope, and an environment override is checked again when the client starts. The modules that install Users are server mode only: Account, Events, Payments, Subscriptions and Preferences.

Options

Options go under the authentication key.

OptionTypeDefaultPurpose
client.issuerstring–Required (option or env). The issuer URL.
client.clientIdstring–Required (option or env). The id of your public OAuth application.
client.scopesstring[]['openid']Requested scopes. Must contain openid.
client.apiBasestringthe issuerBase URL of $api and useApi.
client.apiOriginsstring[]the apiBase and issuer originsOrigins $api sends the bearer token to.
client.onUnauthorized'signOut' | 'none''signOut'What a 401 does that a forced refresh did not fix.
client.redirectUristring<origin><callbackPath>The browser redirect URI.
client.callbackPathstring/auth/callbackPath of the callback page and of the redirect URIs derived from it.
client.native.schemestring–Custom URL scheme, usually the app id.
client.native.redirectUristring–A full native redirect URI instead, for example a claimed https universal link.
client.endpointsPartial<OAuthEndpoints>discoveredauthorization, token, revocation, userinfo and more.
client.discoverybooleantrueFind the endpoints through OpenID Connect discovery.
client.authorizationParamsRecord<string, string>{}Extra authorization request parameters (prompt, audience, ...).
client.storageStorageOptionautoWhere tokens live. See Token storage.
client.mapUserstringthe claims mappingPath to a file whose default export maps the signed-in user. See The user.
client.userinfobooleanfalseThe default mapping merges the issuer's /userinfo response too.
client.redirect.loginstringthe sign-in pageWhere onUnauthorized: 'signOut' and a failed native sign-in send the visitor.
client.redirect.afterLoginstring/After a sign-in that started without a returnTo. A path on this site.
client.redirect.afterLogoutstring/After logout(). A path on this site.
client.dev.rewriteLoopbackboolean | stringtrueRewrites localhost in development on a device.
client.allowClientSecretInBundlebooleanfalseAllow a build that embeds a client secret. See Security.
settings.pages.loginstring | booleantrue (/login)The sign-in page (needs @nuxt/ui). On by default in client mode.
settings.pages.callbackbooleantrueThe callback page at client.callbackPath (needs @nuxt/ui). Client mode only.

client.scopes. Add offline_access for refresh tokens where the provider needs it.

client.apiOrigins. Setting it replaces the default. Any other origin gets no token. The core's own client.fetch sends the token to these origins plus apiBase and the issuer.

client.onUnauthorized. 'signOut' signs out and goes to the sign-in page, then back afterwards. 'none' only throws.

client.native.scheme. The redirect URI is <scheme lowercase>:<callbackPath>. An invalid scheme, for example one with _, fails the build. Mobile fills it in from appId.

client.endpoints. Each is absolute or relative to the issuer. They win over discovery.

client.discovery. Discovery reads openid-configuration and falls back to RFC 8414 (OAuth server metadata). false uses the OAuth Server paths (/oauth/authorize, ...).

client.dev.rewriteLoopback. In development on a device, localhost in the issuer and apiBase becomes the host the app was loaded from. A string sets the host.

settings.pages.login. A string sets another path, false none.

The other settings (password, emailed links, passkeys, OAuth providers) are server mode only. In client mode every feature flag in runtimeConfig.public.authentication is false, so code that checks them works in both modes. useAuthPages() returns the sign-in path in both modes.

Sign-in page branding

Brand the sign-in page in app.config.ts:

app/app.config.ts
export default defineAppConfig({
  authentication: {
    login: { title: 'Household bills', description: 'Sign in to continue.', logo: '/logo.svg', icon: 'i-lucide-wallet' },
  },
})

logo is an image path or { light, dark }. icon shows when there is no logo. The page strings are in the auth.client i18n namespace.

Runtime config

The runtime config is runtimeConfig.public.authentication.client. Your app's own values win over the module options.

VariableOption
NUXT_PUBLIC_AUTHENTICATION_CLIENT_ISSUERissuer
NUXT_PUBLIC_AUTHENTICATION_CLIENT_CLIENT_IDclientId
NUXT_PUBLIC_AUTHENTICATION_CLIENT_SCOPEscopes, as a space separated string
NUXT_PUBLIC_AUTHENTICATION_CLIENT_API_BASEapiBase
NUXT_PUBLIC_AUTHENTICATION_CLIENT_REDIRECT_URIredirectUri (browser)
NUXT_PUBLIC_AUTHENTICATION_CLIENT_NATIVE_REDIRECT_URIthe native redirect URI
NUXT_PUBLIC_AUTHENTICATION_CLIENT_CLIENT_SECRETDevelopment only, against a confidential application. See Security.
  • NUXT_PUBLIC_AUTHENTICATION_CLIENT_SECRET works as well as ..._CLIENT_CLIENT_SECRET.
  • A list set in runtimeConfig.public.authentication.client (apiOrigins) replaces the module's.
  • In a static build the values are baked in when it is generated.

Token storage

The client.storage option decides where tokens live.

ValueWhere
autoSecure storage on a device, localStorage in a browser.
secureKeychain / Keystore (@aparajita/capacitor-secure-storage). Required on a device.
preferences@capacitor/preferences (UserDefaults / SharedPreferences, not encrypted).
local / sessionlocalStorage / sessionStorage.
memorySigned out on every reload.
a file pathA file whose default export is a TokenStorage or a function returning one.

A file path is relative to the app's rootDir, or names a package.

An app with @capacitor/core but without the secure storage plugin fails a production build with auto, because the tokens would sit unencrypted. Install the plugin, or set storage: 'preferences' to accept that.

On a device, an install marker wipes tokens that survived a reinstall. The Capacitor plugins are optional peers: only the installed ones are imported.

App

const { ready, loggedIn, user, session, fetch, clear } = useUserSession()
const { status, authenticated, signingIn, login, completeLogin, logout, getAccessToken, isCallbackUrl, claims, getClient } = useAuth()
const { data } = await useApi<Bill[]>('/api/bills') // client only (`server: false`) by default
const me = await useNuxtApp().$api<Me>('/api/me')
NameWhat it is
useUserSession()Who is signed in, in nuxt-auth-utils' shape.
useAuth()The sign-in state and actions.
useApi(url, options)useFetch through $api, client only by default.
$api$fetch against apiBase. Sends the bearer token to the apiOrigins only, and refreshes and retries once on 401.
<AuthSignInButton return-to="/bills" />A sign-in button (needs @nuxt/ui). Emits error.
/login, /auth/callbackPages (need @nuxt/ui). A page of your app at the same path wins.

useUserSession(). ready turns true once the stored tokens were read. loggedIn is true only when signed in and there is a user (see The user). session is { id, user }, where the id belongs to the sign-in and survives refreshes. clear() is logout({ redirect: false }), and openInPopup() throws.

useUserSession().fetch(). It maps the user again, with a fresh userinfo. It rejects when the mapping failed for a reason other than being offline; offline, it keeps the claims mapping.

useAuth(). status is unknown, signed-in or signed-out. login({ returnTo?, params? }) starts a sign-in and completeLogin(url?) finishes it. logout({ redirect? }) stays on the page with false and goes to a string. getAccessToken() refreshes when needed, and returns null when signed out or on the server. Only in client mode: claims (the ID token claims) and getClient() (the core client).

Pages without @nuxt/ui. Build your own with useAuth().login() and completeLogin().

user.id is the issuer's sub, a string. In server mode it is the local user's id. Code that runs in both modes treats user.id as opaque.

Inside a native app. The plugin completes sign-ins from the cold-start URL (App.getLaunchUrl()) and from appUrlOpen. Only callback URLs count: other deep links are left alone, and a link that answers no pending sign-in is ignored. The plugin refreshes the tokens on resume. A failed native sign-in goes to redirect.login with ?error=sign-in.

The user

The user comes from the ID token.

  • A sign-in whose response has no valid ID token fails in completeLogin, and its tokens are discarded.
  • A stored session without readable claims is signed out and fires auth:user-failed. Causes are a corrupt store, or another issuer or client.
  • So loggedIn always means user !== null.

By default the user is mapped from the claims only, so a cold start offline still knows who is signed in:

{ id: sub, sub, name, email, avatar: picture }

Roles are not read from the token. What the user may do comes from the issuer: useAuthorization() asks its GET /api/auth/me/access with the access token, and falls back to the token's permissions claim. See Client mode.

client.userinfo: true merges the issuer's /userinfo response into that. client.mapUser maps the user yourself, and may be async:

app/auth/map-user.ts
import type { AuthClientUserContext } from '@frigear-nu/auth/client-core'

export default async ({ claims, userinfo, api }: AuthClientUserContext) => ({
  id: claims.sub,
  name: claims.name ?? '',
  avatar: (await userinfo()).picture as string | undefined,
})
nuxt.config.ts
authentication: { mode: 'client', client: { mapUser: './app/auth/map-user' } }
  • userinfo() is fetched once per sign-in. It is fetched again after useUserSession().fetch() or after a refresh that changed the claims.
  • userinfo() rejects a response whose sub is not the ID token's.
  • api is $api. It works while a restored session is mapped at startup.
  • Until an async mapping finishes, user is the claims mapping, so loggedIn never waits on the network.
  • Mapping again within one sign-in keeps the previous result until the new one lands.
  • A result that arrives after a sign-out or another sign-in is dropped.
  • A failure fires auth:user-failed and leaves user at the claims mapping.
  • A transient error is retried at the next refresh: offline, 429, 5xx, or a mapping that takes over 15 seconds.
  • A 401 goes through onUnauthorized.

What the client knows about permissions is a hint for the interface, never access control. See Security.

Guards

Your app chooses which pages need a user, through Authorization, as in server mode. Nothing is protected by default.

nuxt.config.ts
routeRules: {
  '/**': { authenticated: true }, // signed in everywhere
  '/about': { authenticated: false }, // except here
},
  • definePageMeta({ authorize: false }) opens one page.
  • Auth's sign-in page and callback are exempt from less specific rules such as '/**', without a rule of yours.
  • A rule of yours for that exact path, or a more specific one, still applies.
  • In client mode the rules only decide what the app shows. The API decides what is allowed.

A visitor on a guarded page is sent to the sign-in path (client.redirect.login, by default the sign-in page) with the page in ?redirect=. Once signed in, they come back there. Authorization's redirectTo changes the target. redirectTo: false shows a 401 error instead.

Hooks

Your app listens to these runtime hooks on the client with useNuxtApp().hook(...). They exist in client mode only.

HookPayloadWhen
auth:signed-in{ user, claims }After a sign-in completed and its user was mapped. Not when a stored session is restored.
auth:signed-out{ reason }After the session ended.
auth:refresh-failed{ error }A refresh failed but the session was kept (offline, server error, misconfigured client).
auth:user-failed{ error }The user could not be read.

auth:signed-out. The session ends through logout, a rejected refresh token, the API refusing the tokens, or no identity. A stored session cleared at startup only fires auth:user-failed.

auth:user-failed. The causes are a stored session without identity (which is signed out), or a failed mapUser or userinfo mapping.

Security

Guards are for the interface, not access control. The ID token is decoded without a signature check. The stored tokens and the mapped user live in storage the device owner can edit. So a route rule or can() in a client-mode app decides what to show, never what is allowed. Bundled pages must not contain privileged data. Every protected read and write goes to an API that checks authorize.

Why a public application has no secret is explained in Identity.

Sign-in

  • PKCE uses S256, with state, nonce and the iss check. There is no client secret.
  • Pending sign-ins are keyed by state. So a forged callback is ignored and never cancels a real sign-in, and two tabs can sign in at once.
  • An authorization response with a wrong iss is rejected (RFC 9207).

Tokens

  • ID token checks. Auth checks iss, aud / azp, sub and nonce, not the device clock. A refreshed ID token that fails them keeps the previous claims. The rotated tokens are always stored.
  • No signature check. The ID token comes straight from the token endpoint over TLS (OpenID Connect Core 3.1.3.7). Resource servers must verify access tokens themselves, as OAuth Resource does.
  • Refresh rotation. One refresh runs at a time: single-flight in the page, Web Locks across tabs. The stored tokens are re-read under the lock, so a tab never reuses a rotated token. A refresh token that was not rotated is kept.
  • Bearer tokens go to allowed origins only (client.apiOrigins).
  • Redirects stay on this site. returnTo, redirect.afterLogin, redirect.afterLogout and logout({ redirect }) follow the shared rule in Redirects.

Errors

Every request to the issuer times out after 15 seconds. Errors come in four kinds:

KindCausesEffect
sessionAn explicit invalid_grant or similarSigned out.
configinvalid_client, an issuer mismatch, a bare 401Configuration problem.
transientNetwork, timeouts, 429, 5xx, a 4xx other than 401 without an OAuth error bodyThe session is kept.
authorizationA failed sign-inThe sign-in fails.

Client secrets

A client secret in a bundle is readable by anyone. A production build fails when NUXT_PUBLIC_AUTHENTICATION_CLIENT_CLIENT_SECRET, NUXT_PUBLIC_AUTHENTICATION_CLIENT_SECRET or runtimeConfig.public.authentication.client.clientSecret is set.
  • NUXT_PUBLIC_AUTHENTICATION_CLIENT_CLIENT_SECRET is for nuxt dev against a confidential application.
  • allowClientSecretInBundle: true overrides the build check, for throwaway builds.
  • Outside development, clientSecret is not declared at all, so a runtime environment variable cannot reach the bundle's config.

Without Nuxt

The framework-agnostic core, @frigear-nu/auth/client-core, works anywhere with fetch and WebCrypto:

import { createOAuthClient, webStorage } from '@frigear-nu/auth/client-core'

const client = createOAuthClient({
  issuer: 'https://frigear.nu',
  clientId: 'my-app',
  redirectUri: `${location.origin}/callback`,
  scope: 'openid offline_access',
  storage: webStorage(),
  attachTo: ['https://api.example.com'],
})
await client.authorize() // redirects
// on /callback:
const { returnTo } = await client.handleCallback(location.href)
const response = await client.fetch('https://api.example.com/me')
SubpathWhat it has
@frigear-nu/auth/client-coreThe framework-agnostic client: createOAuthClient, createSignInFlow, storages, discovery, PKCE, the errors and the types.
@frigear-nu/auth/capacitorcreateCapacitorOAuthClient and onCapacitorCallback for a Capacitor app without Nuxt, and the adapters.
@frigear-nu/auth/client-typesTypes only: ClientModuleOptions, AuthClientPublicConfig, AuthAppConfig, StorageOption and the core's types.
@frigear-nu/auth/moderesolveAuthMode(nuxt) and isSafeRedirect(value), free of @nuxt/kit, for other modules.
  • The types of client-core include AuthClientUserContext.
  • The adapters of capacitor are secureStorage, preferencesStorage, browserUserAgent and withInstallMarker.
  • TokenStorage and AuthorizationUserAgent are the extension points for another secure storage, or for an ASWebAuthenticationSession user agent.

Next steps

Copyright © 2026