Auth
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.
@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:
kind | Credential | Scopes |
|---|---|---|
session | Sealed cookie from nuxt-auth-utils | Everything (*) |
user-token | Authorization: Bearer fnu_…, a static token the user created | The scopes chosen at creation |
oauth | Authorization: Bearer <JWT>, an access token issued to an application | The 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 },
},
},
})
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
| Option | Type | Default | Purpose |
|---|---|---|---|
tokenTtlDays | number | null | null | Default lifetime for new user tokens; null = never expire. |
userStore | string | the one from Users | Path to a module whose default export is an AuthUserStore. |
scopes | AuthScopeDefinition[] | [] | Extra scopes users can pick for tokens. |
settings | AuthSettings | see below | The sign-in flows. |
settings
| Option | Type | Default | Purpose |
|---|---|---|---|
password | boolean | true | Email/password login and register routes, composables and pages. |
passwordReset | boolean | true with password | Forgot/reset-password routes. Needs password. |
webAuthn | boolean | false | Passkey routes, composables and table. |
magicLink | boolean | { signUp?, cooldown? } | off | Emailed 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). |
verifyEmail | boolean | false | Users who register with a password must verify their address (emailed link) before they can log in; registration creates no session. |
cleanup | string | false | '0 3 * * *' | Cron for the auth:clear-expired-tokens task (expired magic links and resets); false schedules nothing, the task stays registered. |
pages.login | string | boolean | off | Login page. true = /login. |
pages.register | string | boolean | off | Register page. true = /register. May be an absolute URL: the login page links to it and no local page is added. |
afterLogin | string | '/' | 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.mapper | string | pass-through | Path 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 config | Env var | Purpose |
|---|---|---|
previewDeployment | NUXT_PREVIEW_DEPLOYMENT | Enables the login without credentials (set it yourself, e.g. from a CI marker). |
frigear.sso.url | NUXT_FRIGEAR_SSO_URL | Identity provider origin (defaults to the frigear.nu origin). |
frigear.sso.clientId | NUXT_FRIGEAR_SSO_CLIENT_ID | OAuth client id. |
frigear.sso.clientSecret | NUXT_FRIGEAR_SSO_CLIENT_SECRET | OAuth client secret. |
frigear.forceId | NUXT_FRIGEAR_FORCE_ID | Make 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.frigearSso | NUXT_PUBLIC_AUTHENTICATION_FRIGEAR_SSO | Show 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_tokenis verified against the provider'sjwks_uri(cached for an hour), including issuer and audience. Users are matched by email; a verifiedemail_verifiedclaim verifies the address. forceIdupdates a user with that id, re-keys an existing user with the same email (rows referencingusers.idfollow; a different user holding the id moves to the next free id), or creates a user with that id.- The
roleclaim, else the namespacedhttps://frigear.nu/claims/role, else the first ofroles, is applied to the localroleat every login. Without a claim the stored role is left alone. It needs therolecolumn 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 theoauth:claimshook of OAuth Server. - The optional
color_modeclaim (light,dark,system) is kept on the session asfrigear.colorModeand 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)
})
| Call | Behaviour |
|---|---|
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:
| Hook | Payload |
|---|---|
auth:resolvers | AuthResolverDefinition[] (push to register) |
auth:scopes | AuthScopeDefinition[] (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:
| Scope | Module |
|---|---|
events:manage, events:pii, tickets:read, tickets:write | Events |
forms:manage, forms:pii, forms:submit | Forms |
payments:read, payments:write | Payments |
subscriptions:read, subscriptions:write | Subscriptions |
profile:read | Users (with settings.mcp) |
oauth:read | OAuth 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
Authorizationheader only; query-string tokens are ignored. - Tokens can expire (
expiresInDays, ortokenTtlDays) and be revoked.lastUsedAtis 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
| Hook | When | Payload |
|---|---|---|
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 generated | See Extending |
Routes
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /auth/logout | none | Clears the session and redirects to /. |
POST | /api/auth/login | none | Email/password login (password). |
POST | /api/auth/register | none | Email/password registration (password). |
POST | /api/auth/forgot-password | none | Email a reset link for email (passwordReset). |
POST | /api/auth/reset-password | none | Reset with code, password, confirmPassword, then sign in (passwordReset). |
POST | /api/auth/magic-link | none | Email a single-use link. Always answers { ok: true } (magicLink). |
GET | /auth/magic-link?code= | none | Consume 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/register | session for existing users | Register a passkey. An unknown email creates the account; an existing one needs that user's session (webAuthn). |
POST | /api/auth/webauthn/authenticate | none | Sign in with a passkey (webAuthn). |
GET | /api/auth/webauthn/credentials | session | List your passkeys (webAuthn). |
DELETE | /api/auth/webauthn/credentials/:id | session | Remove one of your passkeys (webAuthn). |
GET | /api/auth/github, /google, /facebook | none | OAuth login (oauth.<provider>). |
GET | /auth/frigear | none | Log in with frigear.nu; 404 unless enabled (oauth.frigear). |
GET | /api/auth/tokens | session only | Your tokens: name, prefix, last 4, scopes, expiry, last use, status. |
POST | /api/auth/tokens | session only | Create { name, scopes, expiresInDays? }. The plaintext token is in this response only. |
DELETE | /api/auth/tokens/:id | session only | Revoke; the row is kept for the audit trail. |
GET | /api/auth/scopes | session only | The 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.
| Table | Present when | Holds |
|---|---|---|
auth_user_tokens | always | Hashed user tokens: name, prefix, last 4, scopes, expiry, revoke and last-use timestamps. |
user_credentials | webAuthn | One row per passkey. |
password_resets | passwordReset | Unique reset code with expiresAt / usedAt. |
magic_links | magicLink or verifyEmail | Unique 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.*.