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
modules/auth-routes.ts(Licensees only) readsauthat build time and mounts only the routes it asks for, fromserver/auth/routes/(Licensees only). A method you turn off has no endpoint.- Every sign-in goes through
openSessioninserver/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 insessions, and seals the nuxt-auth-utils cookie with that row's id (sid) and the account'ssession_version. requireSessionreads 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 dailysessions:prunetask deletes dead rows.- 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,
},
primary | Sign-in | New accounts |
|---|---|---|
'password' | Email and password on /login | /register |
'passwordless' | A magic link mailed from /login | The first magic link sent to a new address |
'none' | OAuth only | The first OAuth sign-in with a trusted address |
oauthworks with anyprimary.totpadds 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.emailVerificationapplies underprimary: '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
- Set
oauth: ['github']. - On GitHub, create an OAuth app with the callback URL
https://<your-domain>/api/auth/oauth/github. - Put its credentials in
.env, and in the Worker's secrets for a deploy:NUXT_OAUTH_GITHUB_CLIENT_ID=... NUXT_OAUTH_GITHUB_CLIENT_SECRET=... - 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):
| Provider | Rule | Opens accounts |
|---|---|---|
| GitHub | 'provider-confirmed' | Yes |
'verified-flag' | Only when Google sends email_verified: true | |
| Microsoft | false | Never. 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:
- Add
'discord'toOAUTH_PROVIDERSinshared/starter/providers.ts(Licensees only). - Give it an entry in
OAUTH_HANDLERSinmodules/auth-routes.ts: the nuxt-auth-utils factory, and whether to add the email scope. Every provider's route is wrapped inwithOAuthState, so the new one is too. - Add its trust rule to
EMAIL_TRUST, and its label and icon toOAUTH_METAinapp/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 }
})
| Helper | Answers |
|---|---|
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'andemailVerification: 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-emailasks 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):
| Scope | Keyed by | Allows |
|---|---|---|
password | IP and address | 10 per 15 minutes |
totp, totp-recovery, reauth | Account | 5 per 15 minutes each |
totp-daily | Account | 20 per day |
passkey | IP | 30 per 15 minutes |
register | Address | 10 per hour |
magic-link, password-reset, email-verify | Address | 10 per hour, 1 per minute |
magic-confirm | Account | 3 per hour, 1 per minute |
email-change | New address | 10 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 callsPOST /api/account/restore. After it, the dailyaccount:purgetask 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
| Event | Fires when | Payload |
|---|---|---|
auth:registered | An account is created | userId, email, method, verified |
auth:emailVerified | An account proves its address | userId, email |
auth:emailChanged | A new address lands on the account | userId, email, previousEmail |
account:deletionScheduled | Deletion is requested | userId, purgeAt, subscriptions |
account:purged | The account has been erased | userId |
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
| Key | Default | What it does |
|---|---|---|
auth.enabled | true | Off mounts no auth route and opens the dashboard. |
auth.primary | 'password' | 'password', 'passwordless' or 'none'. |
auth.oauth | [] | Any of 'github', 'google', 'microsoft'. |
auth.totp | true | Authenticator codes as a second factor. |
auth.passkey | 'login' | 'login', 'second-factor' or false. |
auth.emailVerification | true | Password signups confirm their address first. Ignored under 'passwordless'. |
account.deletionGraceDays | 30 | Days a deletion can be undone. false erases at once. |
legal.termsVersion | false | The current terms version. Changing it prompts everyone again. |
access.firstAccountRole | 'superadmin' | Role of the first account to prove its address (Permissions). |
| Variable | What it does |
|---|---|
NUXT_SESSION_PASSWORD | Cookie encryption key, 32+ characters. pnpm dev writes one to .env. |
NUXT_SESSION_COOKIE_SECURE | Set false only for a production build served over plain http. |
NUXT_SITE_URL | Origin of every mailed link. Outside pnpm dev, sending a link fails without it. |
NUXT_OAUTH_<PROVIDER>_CLIENT_ID, NUXT_OAUTH_<PROVIDER>_CLIENT_SECRET | One pair per provider in auth.oauth. |
NUXT_OAUTH_MICROSOFT_TENANT | Required with Microsoft. |
NUXT_PUBLIC_TURNSTILE_SITE_KEY, NUXT_TURNSTILE_SECRET_KEY | Turn on the Turnstile check. Both or neither. |
Good to know
pnpm devhas no Durable Objects, so every auth limit is off there and a[rate-limit]warning is logged. Try lockouts underpnpm preview.- With
emailVerificationoff, 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 underprimary: '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, orNUXT_PUBLIC_SITE_URLon the Worker. - A column of yours that references
users, or is nameduser_id, withoutonDelete: 'cascade'failstest/unit/schema-cascades.test.ts(Licensees only), which readscustom/schema.tstoo.
A link with a lock opens the kit’s private repository, which every licensee can read.