Skip to content

Accounts

Auth

Auth covers sign-in, sessions, second factors, email verification and the account pages under /dashboard/settings. It is switched from the auth block in starter.config.ts(Licensees only). You work here when you pick the sign-in methods, add an OAuth provider or protect a route of your own.

How it works

  1. modules/auth-routes.ts(Licensees only) reads auth at build time and mounts only the routes it asks for, from server/auth/routes/(Licensees only). A method you turn off has no endpoint.
  2. Every sign-in goes through openSession in server/utils/auth.ts(Licensees only). It refuses a suspended or deletion-scheduled account, works out whether a second factor is still owed, writes a row in sessions, and seals the nuxt-auth-utils cookie with that row's id (sid) and the account's session_version.
  3. requireSession reads the row back on every call. A session ends when its row is revoked, when the account's version is bumped, or after 30 days without a request. The daily sessions:prune task deletes dead rows.
  4. Mailed links (verification, magic link, password reset, restore) open a page that posts the token when the person clicks. A mail scanner that prefetches the link spends nothing.

Choose how people sign in

auth: {
  enabled: true,
  primary: 'password',
  oauth: ['github'],
  totp: true,
  passkey: 'login',
  emailVerification: true,
},
primarySign-inNew accounts
'password'Email and password on /login/register
'passwordless'A magic link mailed from /loginThe first magic link sent to a new address
'none'OAuth onlyThe first OAuth sign-in with a trusted address
  • oauth works with any primary.
  • totp adds authenticator codes as a second factor. It is never a way in on its own.
  • passkey: 'login' lets a passkey sign in alone and skips TOTP. 'second-factor' only confirms a session the primary method opened.
  • emailVerification applies under primary: 'password'. Under 'passwordless' an account whose address nobody has proven always waits for its first sign-in by email link, whatever the flag says (Verify email addresses).

/register exists only under 'password'. On other builds it answers 404 and every sign-up link points at /login. Password reset needs mail, so with email.enabled: false its routes are not mounted and the "Forgot password?" link disappears. The build fails on primary: 'none' when nothing could ever sign in, and warns when existing users can sign in but nobody new can register, for example with Microsoft as the only provider.

Add GitHub sign-in

  1. Set oauth: ['github'].
  2. On GitHub, create an OAuth app with the callback URL https://<your-domain>/api/auth/oauth/github.
  3. Put its credentials in .env, and in the Worker's secrets for a deploy:
    NUXT_OAUTH_GITHUB_CLIENT_ID=...
    NUXT_OAUTH_GITHUB_CLIENT_SECRET=...
    
  4. Restart pnpm dev. The login page shows a GitHub button.

A GitHub OAuth app takes one callback URL, so register a second app for local development with http://localhost:3000/api/auth/oauth/github. The button errors until its client id and secret are set.

/api/auth/oauth/<provider> is both the leg that leaves for the provider and the callback. A ?redirect= on the first leg is kept in a cookie for ten minutes and applied after sign-in, so an invitation link survives the round trip. Google works the same way with NUXT_OAUTH_GOOGLE_*. Microsoft also needs NUXT_OAUTH_MICROSOFT_TENANT.

Only the social sign-in started last can finish. nuxt-auth-utils keeps one pending sign-in for all providers, so with two sign-in tabs open the older one returns to the login page with an error.

Which providers can create accounts

An OAuth identity never joins an existing account because the email matches. It signs in only once it is attached to an account: either it created the account, or a signed-in user connected it from Settings → Security. Signing in with a provider whose address already has an account is refused, and the login page explains how to connect it instead.

Whether a provider's address may open a new account is EMAIL_TRUST in shared/starter/oauth-trust.ts(Licensees only):

ProviderRuleOpens accounts
GitHub'provider-confirmed'Yes
Google'verified-flag'Only when Google sends email_verified: true
MicrosoftfalseNever. It can only be connected from Settings

Add a provider the kit doesn't wire

nuxt-auth-utils ships many more handlers than the three the kit wires. To add one, say Discord:

  1. Add 'discord' to OAUTH_PROVIDERS in shared/starter/providers.ts(Licensees only).
  2. Give it an entry in OAUTH_HANDLERS in modules/auth-routes.ts: the nuxt-auth-utils factory, and whether to add the email scope. Every provider's route is wrapped in withOAuthState, so the new one is too.
  3. Add its trust rule to EMAIL_TRUST, and its label and icon to OAUTH_META in app/composables/useStarterAuth.ts(Licensees only).

All three are records over the provider type, so typecheck names what you missed. The callback reads id or sub, email or mail, and name or displayName from the provider's user payload, in oauthCallbackHandlers in server/utils/oauth-shim.ts(Licensees only). A provider that names them differently needs a case there.

Protect a route

On the server, call a session helper first. They are auto-imported from server/utils/auth.ts:

export default defineEventHandler(async (event) => {
  const session = await requireVerifiedSession(event)

  return { id: session.user.id, email: session.user.email }
})
HelperAnswers
requireVerifiedSession(event)401 when signed out, 403 second-factor-required while a factor is owed
requireSession(event)401 when signed out. Lets a session that still owes a factor through
verifySession(event)The session, or null. Never throws
requirePermission(event, 'users:manage')Also checks a role or plan permission (Permissions)

Each one reads the session row, so a revoked, banned or version-bumped session is refused on its next request. getUserSession from nuxt-auth-utils only unseals the cookie and checks none of that, so don't use it to decide who someone is.

In a page, the auth middleware sends a signed-out visitor to /login?redirect=<path>:

<script setup lang="ts">
definePageMeta({ layout: 'dashboard', middleware: 'auth' })

const { user, session } = useUserSession()
</script>

user is a SessionUser from shared/types/session.ts(Licensees only): id, email, name, role, avatar, emailVerified and termsCurrent. loggedIn is already true for a session that still owes its second factor, so code that means "signed in" also checks session.value?.pendingMfa, as the middleware does.

Verify email addresses

An account whose address nobody has proven yet waits for proof:

  • With primary: 'password' and emailVerification: true, signup mails a confirmation link that lasts 24 hours. The link counts only when it is opened in a browser signed in to that account; signed out, /verify-email asks the person to sign in and comes back.
  • With primary: 'passwordless', the proof is one sign-in by email link. A magic-link signup is proven from the start, so an unproven account exists only when something opened a session without that link: an app signing in the account a guest purchase created, or staff marking an address unverified.

Until then the account can sign in and use the dashboard, but these answer 403 with data.reason: 'address-unverified':

  • POST /api/payments/checkout
  • adding a passkey, starting TOTP enrolment and connecting an OAuth provider
  • POST /api/orgs/:org/invitations, PATCH /api/orgs/:org/members/:userId, and accepting an invitation sent to another address (Tenancy)

The dashboard keeps a warning toast up on every page of the dashboard layout. Under password its button resends the confirmation link. Under passwordless it reads Email me a link and posts to POST /api/auth/magic/confirm, which mails the session's own address a sign-in link that lands back on the page; the button comes back as Send another link a minute later. Both are paced at one mail a minute. OAuth accounts start verified.

When an account that has never proven an address proves one, by the verify link, a completed password reset or a sign-in by email link, every passkey, TOTP factor and connected provider it holds is deleted if it has a password. The first sign-in by email link to such an account also signs every other session out, records auth.otherSessionsRevoked in its activity, and cancels a pending address change. To refuse a new way in on a route of your own, start it with requireProvenAddress(event) from #server/auth/address-proof. It is requireVerifiedSession plus the same 403. Outside auth, call awaitsAddressProof(session.user) from #shared/auth/address-proof.

Second factors and passkeys

TOTP enrolment is on Settings → Security. Confirming the first code issues 10 recovery codes of 16 characters, shown once. A recovery code signs in when the authenticator is lost, and generating a new set replaces the old one.

Adding or removing a passkey needs a fresh proof on the same device: a sign-in or a re-authentication within the last 10 minutes. Without one the route answers 403 with data.reason: 'proof-required', and the settings card asks for the password or a passkey (POST /api/auth/reauth, POST /api/auth/reauth/passkey) and retries. Put a route of yours behind the same check with requireRecentProof(event, session) from #server/auth/reauth. Every new passkey mails the owner.

Changing the password, changing the email address and deleting the account ask for the current password when the account has one.

An account can't remove its last way in. The DELETE routes for passkeys and connected providers answer 409 with data.reason: 'last-sign-in-method' when nothing would be left. A removal route of your own calls assertStillReachableIn(tx, userId, { kind: 'passkey', id }) inside a serializable transaction before it deletes, as those routes do.

Sessions

A session lives until it goes 30 days without a request or something revokes it. Settings → Security lists the account's devices and ends one or all the others (GET /api/auth/sessions, DELETE /api/auth/sessions/:id, DELETE /api/auth/sessions).

To sign an account out everywhere, bump its version:

await revokeSessions(event, userId)

To keep the device making the request, keep its row and reseal its cookie:

await revokeSessions(event, session.user.id, { keep: session.sid })
await issueVerifiedSession(event, session.user.id)

Password changes and resets, confirmed email changes and account deletion already revoke sessions. A version bump also voids a pending address change: its confirm link answers 400 link-invalid once the account's session_version has moved past the one it was asked under, or once the session that asked is signed out. When you change a users column the session carries, such as the name or role, call issueVerifiedSession too, so the copy sealed in the cookie matches.

Rate limits and Turnstile

Every credential check reserves an attempt before it runs. The limits are POLICY in server/auth/throttle.ts(Licensees only):

ScopeKeyed byAllows
passwordIP and address10 per 15 minutes
totp, totp-recovery, reauthAccount5 per 15 minutes each
totp-dailyAccount20 per day
passkeyIP30 per 15 minutes
registerAddress10 per hour
magic-link, password-reset, email-verifyAddress10 per hour, 1 per minute
magic-confirmAccount3 per hour, 1 per minute
email-changeNew address10 per hour, 1 per minute

A spent scope locks for 15 minutes, magic-confirm for an hour. POST /api/auth/magic/confirm spends magic-confirm and then the address's magic-link, and hands the account's send back when the address refuses. A passkey sign-in that succeeds hands its attempts back, so only abandoned and failed ceremonies count. The counters live in the AUTH_THROTTLE Durable Object, and the AUTH_RATE_LIMIT binding (20 requests a minute) sheds bursts in front of them. Both are declared in wrangler.jsonc(Licensees only).

Staff holding users:manage can lift a customer's lockout from /dashboard/customers (POST /api/admin/users/unlock). It clears the counters keyed to the account and its address, and the password counters of IPs that failed against it in the last 15 minutes. The per-IP passkey counter and the email-change counter stay.

Turnstile guards password sign-in, registration, reset and magic-link requests, guest checkout and the contact page. The server checks it only when both NUXT_PUBLIC_TURNSTILE_SITE_KEY and NUXT_TURNSTILE_SECRET_KEY are set, and outside pnpm dev the contact page refuses until they are (Contact). pnpm dev runs on Cloudflare's always-pass test keys.

Delete an account

Settings → Profile → Delete account calls DELETE /api/auth/account with the typed email address, plus the current password when the account has one. account.deletionGraceDays decides what happens next:

  • A number (30 as shipped): the kit mails a restore link, deletes every session row and refuses sign-in with 403 sign-in-deletion-scheduled. Until the window closes the link opens /restore-account, which calls POST /api/account/restore. After it, the daily account:purge task erases the account.
  • false: the account is erased in the request.

The restore mail goes out before anything changes, so a failed send fails the request and leaves the account as it was. The build refuses a grace period with email.enabled: false. With organizations on, the request answers 409 sole-owner while the account is the only owner of an organization other people still belong to (Tenancy).

purgeAccount in server/auth/deletion.ts(Licensees only) erases. Deleting the users row cascades to credentials, sessions, tokens, memberships and notifications. Payment records stay with user_id set to null and the stored provider payload redacted. Any table of yours with a user column references users with a cascade, in server/database/custom/schema.ts:

import { integer, pgTable, text } from 'drizzle-orm/pg-core'

import { users } from '../schema'

export const notes = pgTable('notes', {
  id: integer('id').primaryKey().generatedByDefaultAsIdentity(),
  userId: integer('user_id')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  body: text('body').notNull(),
})

Settings → Profile also has Download my data (GET /api/auth/export), a JSON file of the profile, connected providers, entitlements, sessions, activity and passkey labels. Your own tables are not in it until you add them to server/auth/routes/data-export.get.ts(Licensees only).

Track terms acceptance

Set legal.termsVersion to any string. A date reads best:

legal: {
  termsVersion: '2026-09-01',
},

Submitting the register form then records acceptance, and every signed-in user whose recorded version differs gets a dialog to accept the current terms (POST /api/auth/terms/accept). Magic-link and OAuth signups meet the dialog on their first page. Any change to the string prompts everyone again, so change it only for material edits. Acceptances land in users.tos_accepted_version and one tos_acceptances row per version. The pages themselves are content/<locale>/legal/. With false, the shipped value, none of this is built.

React to auth events

EventFires whenPayload
auth:registeredAn account is createduserId, email, method, verified
auth:emailVerifiedAn account proves its addressuserId, email
auth:emailChangedA new address lands on the accountuserId, email, previousEmail
account:deletionScheduledDeletion is requesteduserId, purgeAt, subscriptions
account:purgedThe account has been eraseduserId

A password signup fires auth:registered with verified: false, then auth:emailVerified once the link is opened or a password reset completes. Magic-link and OAuth signups arrive verified. Provision anything real only for a proven address. This server/listeners/welcome.ts queues the crm-sync job from Background jobs:

import { defineListener } from '#server/events/types'
import { enqueue } from '#server/utils/queue'

export default defineListener({
  event: 'auth:registered',

  async handle({ userId, verified }, { event }) {
    if (!verified) return

    await enqueue(event, { kind: 'crm-sync', payload: { userId } })
  },
})

Add a second listener on auth:emailVerified wherever an account can exist unproven: password signups and, under passwordless, guest-checkout accounts and accounts staff marked unverified (Events).

Turn auth off

auth.enabled: false mounts no auth route, and the auth page middleware lets everyone through, so the dashboard is open and has no account menu. The build refuses payments, organizations and notifications while auth is off, since each needs a signed-in user.

Configuration

KeyDefaultWhat it does
auth.enabledtrueOff mounts no auth route and opens the dashboard.
auth.primary'password''password', 'passwordless' or 'none'.
auth.oauth[]Any of 'github', 'google', 'microsoft'.
auth.totptrueAuthenticator codes as a second factor.
auth.passkey'login''login', 'second-factor' or false.
auth.emailVerificationtruePassword signups confirm their address first. Ignored under 'passwordless'.
account.deletionGraceDays30Days a deletion can be undone. false erases at once.
legal.termsVersionfalseThe current terms version. Changing it prompts everyone again.
access.firstAccountRole'superadmin'Role of the first account to prove its address (Permissions).
VariableWhat it does
NUXT_SESSION_PASSWORDCookie encryption key, 32+ characters. pnpm dev writes one to .env.
NUXT_SESSION_COOKIE_SECURESet false only for a production build served over plain http.
NUXT_SITE_URLOrigin of every mailed link. Outside pnpm dev, sending a link fails without it.
NUXT_OAUTH_<PROVIDER>_CLIENT_ID, NUXT_OAUTH_<PROVIDER>_CLIENT_SECRETOne pair per provider in auth.oauth.
NUXT_OAUTH_MICROSOFT_TENANTRequired with Microsoft.
NUXT_PUBLIC_TURNSTILE_SITE_KEY, NUXT_TURNSTILE_SECRET_KEYTurn on the Turnstile check. Both or neither.

Good to know

  • pnpm dev has no Durable Objects, so every auth limit is off there and a [rate-limit] warning is logged. Try lockouts under pnpm preview.
  • With emailVerification off, someone can register a victim's address first, add a passkey or TOTP, and keep that way in after the owner resets the password. Leave it on under primary: 'password'.
  • A password reset never creates a first password. Before you drop a provider from auth.oauth, check for accounts that only ever signed in through it: under 'password' they are locked out.
  • Mailed links use NUXT_SITE_URL, never the request's host. Set it at build, or NUXT_PUBLIC_SITE_URL on the Worker.
  • A column of yours that references users, or is named user_id, without onDelete: 'cascade' fails test/unit/schema-cascades.test.ts(Licensees only), which reads custom/schema.ts too.

A link with a lock opens the kit’s private repository, which every licensee can read.