Modules

Auth

Sign-in (password, magic link, OAuth, passkeys), user API tokens and one requireAuth for cookie sessions, tokens and OAuth access tokens, with scopes.

The Nuxt module that owns authentication: how people sign in, and for every server route, who is calling and how. Users, Authorization, OAuth Server and the feature modules build on it; it depends on none of them and knows users only through the AuthUserStore that Users provides.

Requires@nuxthub/core, @frigear-nu/module-kit, @frigear-nu/notify (sign-in emails), @frigear-nu/menu (logout item) and nuxt-auth-utils. All are installed automatically. Passkeys need the optional peers @simplewebauthn/browser and @simplewebauthn/server; the shipped pages need @nuxt/ui.The config key is authentication, not auth (that belongs to nuxt-auth-utils).

A caller is one of three kinds, all resolved into the same AuthPrincipal:

kindCredentialScopes
sessionSealed cookie from nuxt-auth-utilsEverything (*)
user-tokenAuthorization: Bearer fnu_…, a static token the user createdThe scopes chosen at creation
oauthAuthorization: Bearer <JWT>, an access token issued to an applicationThe scopes the user granted

Quick setup

Users installs Auth for you. Add it yourself when you use it on its own:

pnpm add @frigear-nu/auth
export default defineNuxtConfig({
  modules: ['@nuxthub/core', '@frigear-nu/auth'],
  authentication: {
    settings: {
      password: true,
      pages: { login: true, register: '/sign-up' },
      oauth: { github: true },
    },
  },
})
The module extends the users table (see Database). That only changes the generated Drizzle schema, so generate and commit a migration in your app and in every fixture that includes the module. See Module Kit.

Options

OptionTypeDefaultPurpose
tokenTtlDaysnumber | nullnullDefault lifetime for new user tokens; null = never expire.
userStorestringthe one from UsersPath to a module whose default export is an AuthUserStore.
scopesAuthScopeDefinition[][]Extra scopes users can pick for tokens.
settingsAuthSettingssee belowThe sign-in flows.

settings

OptionTypeDefaultPurpose
passwordbooleantrueEmail/password login and register routes, composables and pages.
passwordResetbooleantrue with passwordForgot/reset-password routes. Needs password.
webAuthnbooleanfalsePasskey routes, composables and table.
magicLinkboolean | { signUp?, cooldown? }offEmailed single-use sign-in link. Following it verifies the address. signUp: true creates accounts for unknown addresses; cooldown = minimum seconds between two links for one user (default 60).
verifyEmailbooleanfalseUsers who register with a password must verify their address (emailed link) before they can log in; registration creates no session.
cleanupstring | false'0 3 * * *'Cron for the auth:clear-expired-tokens task (expired magic links and resets); false schedules nothing, the task stays registered.
pages.loginstring | booleanoffLogin page. true = /login.
pages.registerstring | booleanoffRegister page. true = /register. May be an absolute URL: the login page links to it and no local page is added.
afterLoginstring'/'Where users land after signing in (password, passkey, magic link, OAuth/SSO) when no ?redirect= was requested. Must be an absolute path on this site. A ?redirect= on the login page or on /api/auth/<provider> takes precedence.
session.mapperstringpass-throughPath to a default-export function mapping the stored user to the session user shape.
oauth{ github?, google?, facebook?, frigear? }{}Registers the shipped OAuth routes.

Provider credentials are read by nuxt-auth-utils from NUXT_OAUTH_<PROVIDER>_CLIENT_ID and NUXT_OAUTH_<PROVIDER>_CLIENT_SECRET. Other nuxt-auth-utils providers can be added in the host app.

The login and register pages are enabled per path. With pages.login the module also registers /forgot-password and /reset-password (when passwordReset is on). The login page renders the password form, passkey and magic link controls and OAuth buttons that are actually enabled.

Log in with frigear.nu

oauth.frigear registers GET /auth/frigear, an OIDC login against frigear.nu for PR previews and local development. It is decided per request: active when runtimeConfig.previewDeployment is true or runtimeConfig.frigear.sso has a client id and secret; otherwise the route answers 404 and the button is hidden. Register <origin>/auth/frigear as the callback on frigear.nu.

Runtime configEnv varPurpose
previewDeploymentNUXT_PREVIEW_DEPLOYMENTEnables the login without credentials (set it yourself, e.g. from a CI marker).
frigear.sso.urlNUXT_FRIGEAR_SSO_URLIdentity provider origin (defaults to the frigear.nu origin).
frigear.sso.clientIdNUXT_FRIGEAR_SSO_CLIENT_IDOAuth client id.
frigear.sso.clientSecretNUXT_FRIGEAR_SSO_CLIENT_SECRETOAuth client secret.
frigear.forceIdNUXT_FRIGEAR_FORCE_IDMake the local users.id equal the frigear.nu id (default false).
frigear.syncRole-Apply the role claim at every login (default true). Declare it in your runtimeConfig to override.
public.authentication.frigearSsoNUXT_PUBLIC_AUTHENTICATION_FRIGEAR_SSOShow the login button; a build-time default, overridable at runtime.

Instead of frigear.sso, the credentials can come from nuxt-auth-utils' NUXT_OAUTH_OIDC_CLIENT_ID, NUXT_OAUTH_OIDC_CLIENT_SECRET and NUXT_OAUTH_OIDC_OPENID_CONFIG.

  • The id_token is verified against the provider's jwks_uri (cached for an hour), including issuer and audience. Users are matched by email; a verified email_verified claim verifies the address.
  • forceId updates a user with that id, re-keys an existing user with the same email (rows referencing users.id follow; a different user holding the id moves to the next free id), or creates a user with that id.
  • The role claim, else the namespaced https://frigear.nu/claims/role, else the first of roles, is applied to the local role at every login. Without a claim the stored role is left alone. It needs the role column of Authorization. These claim names follow the current frigear.nu and will change when this app becomes frigear.nu. The provider side is the host's: add the claim in the oauth:claims hook of OAuth Server.
  • The optional color_mode claim (light, dark, system) is kept on the session as frigear.colorMode and applied once per login.

Server

Protecting a route

requireAuth and getAuth are auto-imported in server code; use them instead of nuxt-auth-utils' requireUserSession.

// server/api/tickets.get.ts
export default defineEventHandler(async (event) => {
  const { user } = await requireAuth(event, { scopes: ['tickets:read'] })
  return getTickets(user.id)
})
CallBehaviour
requireAuth(event)The principal from any credential. 401 without a valid one.
requireAuth(event, { scopes })Also needs every scope. 403 insufficient_scope otherwise. Sessions pass.
requireAuth(event, { sessionOnly: true })Cookie sessions only. 403 session_required for tokens.
getAuth(event)The principal or null. Never throws.
type AuthPrincipal =
  | { kind: 'session', user: AuthUser, scopes: string[] }
  | { kind: 'user-token', user: AuthUser, scopes: string[], token: { id: string, name: string, prefix: string } }
  | { kind: 'oauth', user: AuthUser, scopes: string[], client: { id: string } }

interface RequireUserOptions {
  scopes?: string[]
  sessionOnly?: boolean
}

Users builds requireUser(event, options) and getUser(event, { scopes }) on top, returning just the user.

Other server helpers:

mapUserToSession(storedUser) // strips the password hash and applies `session.mapper`; use it to refresh the session
getLoginPath(event) // the login page path (`pages.login`, else `/login`)
deleteUserAuthData(userId) // removes tokens, passkeys, reset codes and sign-in links (call when deleting a user)

Which routes should accept tokens? Default to session only for anything that changes who the user is or how they sign in (password, email, passkeys, profile, cookie consent, billing portal, OAuth consent, the users admin, managing API tokens). Use scopes for reads and actions an integration plausibly needs: tickets, subscriptions, form submissions.

Roles stay authoritative. A token carries no role: the user is loaded from the database on every bearer request, so a role change, a ban or a deleted account applies to the next call. A cookie session holds a snapshot from login time, so a role change reaches it after the next sign-in (see Authorization).

A request that carries a token a resolver claims (fnu_… or a JWT) and it is invalid, expired or revoked is unauthenticated (401, WWW-Authenticate: Bearer error="invalid_token"), even with a valid session cookie. Tokens no resolver claims, such as per-form ffk_ tokens, are ignored and the other credentials apply.

Policy gates

Staff gates are decided by the host through a Nitro hook, denied by default. A module defines them without knowing who is staff:

const gate = definePolicyGate('events:authorize')
await gate.assert(event, userId) // 403 unless a hook listener sets ctx.allowed = true

// A Nitro plugin that only offers these scopes to users the gate lets in
export default defineStaffScopes(['events:manage', 'events:pii'], gate.isAllowed)

Host listeners must identify the caller from ctx.userId, never from the session on ctx.event: the session is empty for bearer requests.

Extending (build time)

A module's setup() can import these from @frigear-nu/auth/utils:

addAuthScope(nuxt, { name: 'reports:admin', description: 'Run reports', roles: ['admin'] })

addAuthResolver(nuxt, {
  name: 'my-tokens',
  prefix: 'mt_', // or `jwt: true`; one of the two is required
  resolver: resolver.resolve('./runtime/server/resolvers/my-token'),
  order: 100, // lower runs first
})

setAuthUserStore(nuxt, resolver.resolve('./runtime/server/user-store')) // first registration wins
// resolver: return a principal, or null when the token is invalid
export default async function (event: H3Event, token: string): Promise<AuthPrincipal | null> { … }
interface AuthUserStore {
  findById(id: string | number): Promise<AuthStoredUser | null>
  findByEmail(email: string): Promise<AuthStoredUser | null>
  create(values: AuthUserCreate): Promise<AuthStoredUser>
  update(id: number, values: AuthUserUpdate): Promise<AuthStoredUser | null>
  remove(id: number): Promise<void>
  /** Gives the user a new id, taking everything that references it along. */
  changeId(from: number, to: number): Promise<void>
}

The same helpers are also available as Nuxt build hooks, for modules that prefer hooks:

HookPayload
auth:resolversAuthResolverDefinition[] (push to register)
auth:scopesAuthScopeDefinition[] (push to register)
auth:user-store{ path?: string } (set path)

The utils also export ALL_SCOPES, hasScope, hasScopes, isPiiScope, isGrantableScope, parseScopeString, USER_TOKEN_PREFIX, generateUserToken, hashToken, parseBearer, looksLikeJwt and tokenStatus.

Scopes

Scopes are resource:action strings. * grants everything and resource:* every action of one resource. Personal-data scopes (resource:pii) are the exception: neither * nor resource:* covers them, so a token only sees other people's personal data when the user picked that scope by name. Sessions are full access, so MCP tools strip personal data for them as well. Scopes only narrow: a token never does more than its owner can.

A scope is only offered to users who may use it: the token UI lists, and POST /api/auth/tokens accepts, just the scopes available to the signed-in user. Restrict a scope with roles, or for other policy set ctx.available = false in the auth:scope-available hook:

nitroApp.hooks.hook('auth:scope-available', (ctx) => {
  if (ctx.scope.name === 'reports:export') ctx.available = canExport(ctx.user.id)
})
interface AuthScopeDefinition {
  name: string
  description?: string
  /** Only users with one of these roles may pick the scope. Omit for every user. */
  roles?: string[]
}

Registered by the built-in modules:

ScopeModule
events:manage, events:pii, tickets:read, tickets:writeEvents
forms:manage, forms:pii, forms:submitForms
payments:read, payments:writePayments
subscriptions:read, subscriptions:writeSubscriptions
profile:readUsers (with settings.mcp)
oauth:readOAuth Server

User API tokens

Users create tokens (fnu_…) on the account page (/account/api-tokens, see Account) or through the API. Use one like this, for example from an MCP client:

curl -H "Authorization: Bearer fnu_…" https://example.com/api/tickets
  • Only the SHA-256 of a token is stored, plus a prefix and the last four characters. A lost token is replaced, not looked up.
  • Tokens are accepted in the Authorization header only; query-string tokens are ignored.
  • Tokens can expire (expiresInDays, or tokenTtlDays) and be revoked. lastUsedAt is written at most every five minutes and a failure to write it never fails a request.

OAuth access tokens

With OAuth Server, an application the user signed in to calls the API as that user: principal.user is the user and principal.client.id the application.

const principal = await requireAuth(event, { scopes: ['tickets:read'] })
if (principal.kind === 'oauth') console.log(`called by application ${principal.client.id}`)

The OAuth server accepts every registered API scope besides openid profile email. On every request the token is checked against live state: the application must be active, the user must exist and still pass the application's role requirement and the oauth:application:access hook, so disabling an application or changing a role cuts access immediately. ID tokens are not credentials; only access tokens (typ: at+jwt) authenticate. The audience is the application, so aud is not required to match this server; verification is signature (JWKS), issuer and type.

A static user token is enough for an MCP client that sends Authorization: Bearer … (MCP). Clients that need the full OAuth flow also need protected-resource metadata (RFC 9728) and dynamic client registration, which this module does not provide.

Auth emails

Magic link, verification and password reset emails are dispatched as EmailNotifications through Notify (configure the delivery driver under the notify key). With Email Templates installed, they are rendered from the module's Vue templates (auth-magic-link, auth-verify-email, auth-password-reset). Override one by adding app/emails/<name>.vue.

Without templates, you can brand the magic link and verification emails by replacing subject/body in the auth:email hook. Delete ctx.notification.template there if a template should not render over your body:

export default defineNitroPlugin((nitro) => {
  nitro.hooks.hook('auth:email', (ctx) => {
    // ctx.type: 'magic-link' | 'verify-email'; ctx.data: { name, url }
    ctx.notification.subject = 'Velkommen til Frigear'
    ctx.notification.body = renderMyTemplate(ctx.data)
  })
})

App

All composables are auto-imported and can be overridden by re-declaring one of the same name in the app.

const { login, pending, error, isEnabled, passwordLoginEnabled, passkeyLoginEnabled, oauthProviders } = useLogin()
await login({ email: 'ada@example.com', password: 'secret' })

const { register, pending, error, isEnabled } = useRegistration()
await register({ name: 'Ada', email: 'ada@example.com', password: 'secret' })

const { requestReset, resetPassword, pending, error, isEnabled } = usePasswordReset()
await requestReset({ email: 'ada@example.com' })
await resetPassword({ code, password, confirmPassword }) // signs the user in

const { sendMagicLink, pending, error, isEnabled } = useMagicLink()
await sendMagicLink({ email: 'ada@example.com', name: 'Ada', redirect: '/dashboard' }) // redirect: same-origin path

const { isEnabled, registerPasskey, authenticateWithPasskey, listPasskeys, removePasskey } = usePasskeys()
await registerPasskey(user.email, 'MacBook') // the name is optional
const passkeys = await listPasskeys() // [{ id, name, createdAt, counter, backedUp, transports }], never the public key
await removePasskey(passkeys[0].id) // only the signed-in user's own (404 otherwise)

const { login: loginPath, register: registerPath } = useAuthPages() // '/login' by default

The module also registers the AuthCard component when a login or local register page is enabled.

Hooks

HookWhenPayload
auth:scope-available (Nitro)A user's available token scopes are computed{ event, user: { id, role? }, scope, available }: set available = false to hide
auth:email (Nitro)A magic-link or verification email is about to be sent{ type, notification, data: { name, url } }
auth:resolvers, auth:scopes, auth:user-store (Nuxt)Build time, when the virtual modules are generatedSee Extending

Routes

MethodPathAuthPurpose
GET/auth/logoutnoneClears the session and redirects to /.
POST/api/auth/loginnoneEmail/password login (password).
POST/api/auth/registernoneEmail/password registration (password).
POST/api/auth/forgot-passwordnoneEmail a reset link for email (passwordReset).
POST/api/auth/reset-passwordnoneReset with code, password, confirmPassword, then sign in (passwordReset).
POST/api/auth/magic-linknoneEmail a single-use link. Always answers { ok: true } (magicLink).
GET/auth/magic-link?code=noneConsume the link, verify the email, sign in, redirect. A bad link goes to the login page with ?error=magic-link (magicLink or verifyEmail).
POST/api/auth/webauthn/registersession for existing usersRegister a passkey. An unknown email creates the account; an existing one needs that user's session (webAuthn).
POST/api/auth/webauthn/authenticatenoneSign in with a passkey (webAuthn).
GET/api/auth/webauthn/credentialssessionList your passkeys (webAuthn).
DELETE/api/auth/webauthn/credentials/:idsessionRemove one of your passkeys (webAuthn).
GET/api/auth/github, /google, /facebooknoneOAuth login (oauth.<provider>).
GET/auth/frigearnoneLog in with frigear.nu; 404 unless enabled (oauth.frigear).
GET/api/auth/tokenssession onlyYour tokens: name, prefix, last 4, scopes, expiry, last use, status.
POST/api/auth/tokenssession onlyCreate { name, scopes, expiresInDays? }. The plaintext token is in this response only.
DELETE/api/auth/tokens/:idsession onlyRevoke; the row is kept for the audit trail.
GET/api/auth/scopessession onlyThe scopes that can be granted to you.

Magic and verification links expire after 2 hours and are consumed atomically.

Database

None of the tables has a foreign key to users (this module sits below it); whoever deletes a user calls deleteUserAuthData(userId), as the users admin does.

TablePresent whenHolds
auth_user_tokensalwaysHashed user tokens: name, prefix, last 4, scopes, expiry, revoke and last-use timestamps.
user_credentialswebAuthnOne row per passkey.
password_resetspasswordResetUnique reset code with expiresAt / usedAt.
magic_linksmagicLink or verifyEmailUnique single-use code, optional internal redirectUrl, expiresAt / usedAt.

It also adds password, email_verified_at and last_login_at to the users table owned by Users (ignored without it).

Translations

The module registers da and en locale files under the auth.* namespace, read through @nuxtjs/i18n; without it the English defaults are used. Override strings in your app's i18n/locales/<code>.json, see overriding module strings:

{ "auth": { "signIn": "Log in", "email": { "label": "Your email" } } }

Key groups: signIn, signUp, signOut, signedInAs, name.*, email.*, password.*, forgotPassword.*, resetPassword.*, magicLink.*, verifyEmail.*, confirm.*, passkey.*, emailPassword.*, passkeys.*, oauth.<provider>.label, menu.* and errors.*.

Copyright © 2026