Auth client mode
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.
@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.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.
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
Register a public OAuth application at the issuer with these redirect URIs:
- browser:
<origin>/auth/callback, for examplehttp://localhost:3000/auth/callback; - native:
<scheme in lowercase>:/auth/callback, for examplecom.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
<script setup lang="ts">
const { user } = useUserSession()
const { logout } = useAuth()
const { data: bills } = await useApi<Bill[]>('/api/bills')
</script>
/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:
| Check | What happens | Why |
|---|---|---|
| Server-side rendering (SSR) is off | Client 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 required | Scopes without openid fail the build. | The signed-in user comes from the ID token. |
| No server modules | nuxt-auth-utils and Users fail the build, naming the module that installed them. | They need a server and a database. |
| Guards need Authorization | routeRules 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.
| Option | Type | Default | Purpose |
|---|---|---|---|
client.issuer | string | – | Required (option or env). The issuer URL. |
client.clientId | string | – | Required (option or env). The id of your public OAuth application. |
client.scopes | string[] | ['openid'] | Requested scopes. Must contain openid. |
client.apiBase | string | the issuer | Base URL of $api and useApi. |
client.apiOrigins | string[] | the apiBase and issuer origins | Origins $api sends the bearer token to. |
client.onUnauthorized | 'signOut' | 'none' | 'signOut' | What a 401 does that a forced refresh did not fix. |
client.redirectUri | string | <origin><callbackPath> | The browser redirect URI. |
client.callbackPath | string | /auth/callback | Path of the callback page and of the redirect URIs derived from it. |
client.native.scheme | string | – | Custom URL scheme, usually the app id. |
client.native.redirectUri | string | – | A full native redirect URI instead, for example a claimed https universal link. |
client.endpoints | Partial<OAuthEndpoints> | discovered | authorization, token, revocation, userinfo and more. |
client.discovery | boolean | true | Find the endpoints through OpenID Connect discovery. |
client.authorizationParams | Record<string, string> | {} | Extra authorization request parameters (prompt, audience, ...). |
client.storage | StorageOption | auto | Where tokens live. See Token storage. |
client.mapUser | string | the claims mapping | Path to a file whose default export maps the signed-in user. See The user. |
client.userinfo | boolean | false | The default mapping merges the issuer's /userinfo response too. |
client.redirect.login | string | the sign-in page | Where onUnauthorized: 'signOut' and a failed native sign-in send the visitor. |
client.redirect.afterLogin | string | / | After a sign-in that started without a returnTo. A path on this site. |
client.redirect.afterLogout | string | / | After logout(). A path on this site. |
client.dev.rewriteLoopback | boolean | string | true | Rewrites localhost in development on a device. |
client.allowClientSecretInBundle | boolean | false | Allow a build that embeds a client secret. See Security. |
settings.pages.login | string | boolean | true (/login) | The sign-in page (needs @nuxt/ui). On by default in client mode. |
settings.pages.callback | boolean | true | The 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:
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.
| Variable | Option |
|---|---|
NUXT_PUBLIC_AUTHENTICATION_CLIENT_ISSUER | issuer |
NUXT_PUBLIC_AUTHENTICATION_CLIENT_CLIENT_ID | clientId |
NUXT_PUBLIC_AUTHENTICATION_CLIENT_SCOPE | scopes, as a space separated string |
NUXT_PUBLIC_AUTHENTICATION_CLIENT_API_BASE | apiBase |
NUXT_PUBLIC_AUTHENTICATION_CLIENT_REDIRECT_URI | redirectUri (browser) |
NUXT_PUBLIC_AUTHENTICATION_CLIENT_NATIVE_REDIRECT_URI | the native redirect URI |
NUXT_PUBLIC_AUTHENTICATION_CLIENT_CLIENT_SECRET | Development only, against a confidential application. See Security. |
NUXT_PUBLIC_AUTHENTICATION_CLIENT_SECRETworks 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.
| Value | Where |
|---|---|
auto | Secure storage on a device, localStorage in a browser. |
secure | Keychain / Keystore (@aparajita/capacitor-secure-storage). Required on a device. |
preferences | @capacitor/preferences (UserDefaults / SharedPreferences, not encrypted). |
local / session | localStorage / sessionStorage. |
memory | Signed out on every reload. |
| a file path | A 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.
@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')
| Name | What 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/callback | Pages (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
loggedInalways meansuser !== 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:
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,
})
authentication: { mode: 'client', client: { mapUser: './app/auth/map-user' } }
userinfo()is fetched once per sign-in. It is fetched again afteruseUserSession().fetch()or after a refresh that changed the claims.userinfo()rejects a response whosesubis not the ID token's.apiis$api. It works while a restored session is mapped at startup.- Until an async mapping finishes,
useris the claims mapping, sologgedInnever 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-failedand leavesuserat the claims mapping. - A transient error is retried at the next refresh: offline,
429,5xx, or a mapping that takes over 15 seconds. - A
401goes throughonUnauthorized.
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.
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.
| Hook | Payload | When |
|---|---|---|
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
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,nonceand theisscheck. 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
issis rejected (RFC 9207).
Tokens
- ID token checks. Auth checks
iss,aud/azp,subandnonce, 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.afterLogoutandlogout({ redirect })follow the shared rule in Redirects.
Errors
Every request to the issuer times out after 15 seconds. Errors come in four kinds:
| Kind | Causes | Effect |
|---|---|---|
session | An explicit invalid_grant or similar | Signed out. |
config | invalid_client, an issuer mismatch, a bare 401 | Configuration problem. |
transient | Network, timeouts, 429, 5xx, a 4xx other than 401 without an OAuth error body | The session is kept. |
authorization | A failed sign-in | The sign-in fails. |
Client secrets
NUXT_PUBLIC_AUTHENTICATION_CLIENT_CLIENT_SECRET, NUXT_PUBLIC_AUTHENTICATION_CLIENT_SECRET or
runtimeConfig.public.authentication.client.clientSecret is set.NUXT_PUBLIC_AUTHENTICATION_CLIENT_CLIENT_SECRETis fornuxt devagainst a confidential application.allowClientSecretInBundle: trueoverrides the build check, for throwaway builds.- Outside development,
clientSecretis 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')
| Subpath | What it has |
|---|---|
@frigear-nu/auth/client-core | The framework-agnostic client: createOAuthClient, createSignInFlow, storages, discovery, PKCE, the errors and the types. |
@frigear-nu/auth/capacitor | createCapacitorOAuthClient and onCapacitorCallback for a Capacitor app without Nuxt, and the adapters. |
@frigear-nu/auth/client-types | Types only: ClientModuleOptions, AuthClientPublicConfig, AuthAppConfig, StorageOption and the core's types. |
@frigear-nu/auth/mode | resolveAuthMode(nuxt) and isSafeRedirect(value), free of @nuxt/kit, for other modules. |
- The types of
client-coreincludeAuthClientUserContext. - The adapters of
capacitoraresecureStorage,preferencesStorage,browserUserAgentandwithInstallMarker. TokenStorageandAuthorizationUserAgentare the extension points for another secure storage, or for anASWebAuthenticationSessionuser agent.
Next steps
- Mobile starter – a Capacitor app that already runs in client mode.
- OAuth Resource – verify the access token in the API your app calls.
- Authorization – the guards, in both modes.