English
Two-Factor Authentication & Passkeys
hazejs ships two ways to strengthen logins:
- TOTP (RFC 6238): six-digit codes from an authenticator app (Google Authenticator, 1Password, Authy, …), with recovery codes. Built in, no dependencies.
- Passkeys (WebAuthn): sign in with Touch ID, Windows Hello, a phone or a security key. Built on
@simplewebauthn/server, an optional dependency.
Both store their data through small store interfaces. Use the database stores from @hazejs/orm with the tables generated by haze make:auth-tables (see API Tokens); the default in-memory stores are meant for tests.
TOTP
Configuration
ts
// config/auth.ts
import { defineAuthConfig } from '@hazejs/core'
import { databaseTwoFactorStore, modelUserProvider } from '@hazejs/orm'
import { User } from '~/app/model/User'
import { twoFactorSecrets } from '~/database/schema/auth'
export default defineAuthConfig(() => ({
default: 'web',
guards: {
web: { driver: 'session', provider: 'users' },
},
providers: {
users: modelUserProvider(() => User, { username: 'email' }),
},
twoFactor: {
store: databaseTwoFactorStore(twoFactorSecrets),
issuer: 'Acme',
},
}))| Option | Default | Description |
|---|---|---|
provider | default guard's provider | Where users come from |
store | in memory | databaseTwoFactorStore() or any TwoFactorStore |
issuer | app.name | Service name shown in the authenticator app |
account | 'email' | User field used as the account label |
digits / period / algorithm | 6 / 30 / 'SHA1' | Code parameters. Most apps only support the defaults |
window | 1 | Accepted clock drift, in time steps (±30 seconds) |
recoveryCodes | 8 | Number of recovery codes |
challengeTtl | '5m' | How long a login waits for the code |
maxAttempts | 5 | Wrong codes allowed per user. They are counted in the cache for challengeTtl (not in the session cookie the client holds), so after that many the user cannot complete a challenge until the window passes, even after entering the password again |
cacheStore | default cache store | Cache store (from config/cache.ts) that keeps the attempt counters |
What is stored:
- the secret, encrypted with
APP_KEY(the same AES-GCM encryption asCrypt); - recovery codes as SHA-256 hashes, so they cannot be read back;
- the time step of the last accepted code: a code can only be used once, and codes older than the last accepted one are refused.
Enabling 2FA
Enabling is a two-step process: generate a secret, then confirm it with a code from the app. Until it is confirmed, 2FA is not active.
ts
// app/controller/TwoFactorController.ts
import type { HttpContext } from '@hazejs/core'
import { BadRequestException, TwoFactor } from '@hazejs/core'
export class TwoFactorController {
/** Step 1: show a QR code and the recovery codes */
async enable(ctx: HttpContext) {
const { secret, uri, recoveryCodes } = await TwoFactor.enable((await ctx.auth.user())!)
return { secret, uri, recoveryCodes }
}
/** Step 2: the user types the first code */
async confirm(ctx: HttpContext) {
const { code } = await ctx.body<{ code: string }>()
if (!await TwoFactor.confirm((await ctx.auth.user())!, code))
throw new BadRequestException('Invalid code')
return { enabled: true }
}
async disable(ctx: HttpContext) {
await TwoFactor.disable((await ctx.auth.user())!)
return ctx.noContent()
}
}enable() returns the base32 secret (for manual entry), an otpauth://totp/… uri and the plain-text recoveryCodes. This is the only time the recovery codes are visible. Once 2FA is confirmed, enable() throws a 409 so that someone holding a live session cannot silently turn it off; pass { force: true } to replace the secret (ideally after checking a current code with verify()), which returns to the unconfirmed state.
QR codes are rendered in the browser
The server does not generate images. Render uri as a QR code on the client, for example with qrcode (QRCode.toDataURL(uri)) or uqr (renderSVG(uri)).
The TwoFactor facade
| Method | Description |
|---|---|
enable(user, { force? }) | New secret and recovery codes (not active until confirmed); 409 when already enabled unless force |
confirm(user, code) | Activates 2FA if the code is valid; emits TwoFactorEnabled |
enabled(user) | Whether 2FA is active (confirmed) |
verify(user, code) | Checks a code for a user with active 2FA; each time step is accepted once |
useRecoveryCode(user, code) | Consumes a recovery code (case-insensitive) |
regenerateRecoveryCodes(user) | New plain-text codes; the old ones stop working. Throws an Error when the user has no 2FA record (call enable() first) |
remainingRecoveryCodes(user) | How many recovery codes are left |
uri(user) | The otpauth:// URI again, e.g. to show the QR code during setup |
disable(user) | Deletes the secret and codes; emits TwoFactorDisabled |
Failed verify() and useRecoveryCode() calls emit TwoFactorFailed, which you can listen to for alerts or lockouts. See Events.
Session login with a challenge
With a session guard, TwoFactor.attempt() replaces ctx.auth.session().attempt(). It checks the password and, when 2FA is active, stores a pending login in the session instead of logging in:
ts
// app/controller/SessionController.ts
import type { HttpContext } from '@hazejs/core'
import { TwoFactor, UnauthorizedException, validate } from '@hazejs/core'
import { LoginSchema } from '~/app/validate/auth'
export class SessionController {
async store(ctx: HttpContext) {
const result = await TwoFactor.attempt(ctx, await validate(LoginSchema, await ctx.body()))
if (result.status === 'failed')
throw new UnauthorizedException('Invalid email or password')
// 'authenticated': logged in; 'two-factor': ask for the code
return { status: result.status }
}
async challenge(ctx: HttpContext) {
const { code, recoveryCode } = await ctx.body<{ code?: string, recoveryCode?: string }>()
const user = await TwoFactor.challenge(ctx, { code, recoveryCode })
if (!user)
throw new UnauthorizedException('Invalid code')
return user
}
}ts
// route/web.ts
r.post('/login', [SessionController, 'store']).middleware('throttle:10,60')
r.post('/two-factor-challenge', [SessionController, 'challenge']).middleware('throttle:10,60')TwoFactor.challenge(ctx, input) accepts { code } or { recoveryCode }. On success it logs the user in through the session guard (regenerating the session id) and returns the user; otherwise it returns undefined. The challenge expires after challengeTtl. Wrong codes are counted per user in the cache rather than in the session (which, with the default cookie driver, the client holds and could replay), and after maxAttempts of them the challenge is cleared and the user cannot complete another one until challengeTtl has passed, even after entering the password again. TwoFactor.hasPendingChallenge(ctx) tells the frontend whether to show the code form. attempt() takes { guard } to pick a session guard other than the default; it records that guard in the session, and challenge() logs in through it (challenge() itself has no guard option).
JWT and token APIs
The challenge flow needs a session. For stateless APIs, check the code yourself before issuing a token, for example by accepting it with the login request:
ts
async login(ctx: HttpContext) {
const { code, ...credentials } = await ctx.body<{ email: string, password: string, code?: string }>()
const user = await ctx.auth.jwt().validate(credentials)
if (!user)
throw new UnauthorizedException('Invalid email or password')
if (await TwoFactor.enabled(user) && !(code && await TwoFactor.verify(user, code)))
throw new UnauthorizedException('A valid two-factor code is required')
return ctx.auth.jwt().login(user)
}Low-level helpers
The TOTP primitives are exported for custom flows. They run on Web Crypto, so they work on every runtime:
ts
import { generateRecoveryCodes, generateTotpSecret, hotp, otpauthUri, totp, verifyTotp } from '@hazejs/core'
const secret = generateTotpSecret() // base32, 160 bits
const uri = otpauthUri({ secret, issuer: 'Acme', account: 'ada@example.com' })
const code = await totp(secret) // current code
const step = await verifyTotp(secret, code, { window: 1, afterStep: lastStep }) // matched step or undefined
await hotp(secret, 0) // RFC 4226 counter-based code
generateRecoveryCodes(8) // ['k3j9d-x2m4p', …]base32Encode() / base32Decode() convert secrets, and totpStep() returns the current time step. The implementation is checked against the RFC 6238 and RFC 4226 test vectors.
Passkeys (WebAuthn)
Install the server library (v13 or v14):
bash
pnpm add @simplewebauthn/serverIt is an optional peer dependency of @hazejs/core, loaded on first use. Production builds work without it as long as passkeys are not used.
Configuration
ts
// config/auth.ts
import { databasePasskeyStore } from '@hazejs/orm'
import { webauthnCredentials } from '~/database/schema/auth'
export default defineAuthConfig(() => ({
// …
passkeys: {
store: databasePasskeyStore(webauthnCredentials),
rpName: 'Acme',
},
}))| Option | Default | Description |
|---|---|---|
provider | default guard's provider | Where users come from |
store | in memory | databasePasskeyStore() or any PasskeyStore |
rpName | app.name | Name shown by the browser |
rpID | host of APP_URL, else of the request | The domain passkeys are bound to (no scheme or port) |
origin | origin of APP_URL, else of the request | Accepted origin(s), e.g. ['https://example.com', 'https://app.example.com'] |
challenge | 'session' | Where challenges are kept: 'session', or 'cache' for APIs without sessions |
cacheStore | default cache store | Cache store name when challenge: 'cache' |
timeout | 60000 | Milliseconds the browser waits; challenges expire after the same time |
userVerification | 'preferred' | 'required' forces biometrics / PIN |
residentKey | 'preferred' | Discoverable credentials (sign in without typing a username) |
userName / displayName | 'email' / 'name' | User fields shown by the authenticator |
Challenges are single-use. In cache mode they are keyed by the challenge value itself, so no cookie is needed. In session mode a used challenge is also recorded in the cache store, so replaying an old session cookie together with the same response is rejected.
Local development and proxies
The rpID must match the host of the page the user is on. localhost works over plain HTTP; other hosts need HTTPS. Behind a proxy or a separate frontend dev server, set APP_URL (or rpID and origin) to the address the browser actually shows.
Registering a passkey
Registration needs a logged-in user. The server creates options, the browser talks to the authenticator, and the server verifies and stores the credential:
ts
// app/controller/PasskeyController.ts
import type { HttpContext, PasskeyResponseJSON } from '@hazejs/core'
import { Passkeys } from '@hazejs/core'
export class PasskeyController {
async registrationOptions(ctx: HttpContext) {
return Passkeys.registrationOptions(ctx, (await ctx.auth.user())!)
}
async register(ctx: HttpContext) {
const { response, name } = await ctx.body<{ response: PasskeyResponseJSON, name?: string }>()
const credential = await Passkeys.verifyRegistration(ctx, (await ctx.auth.user())!, response, { name })
return ctx.created({ id: credential.id, name: credential.name })
}
async loginOptions(ctx: HttpContext) {
return Passkeys.authenticationOptions(ctx)
}
async login(ctx: HttpContext) {
return Passkeys.login(ctx, await ctx.body<PasskeyResponseJSON>())
}
}ts
// route/web.ts
r.post('/passkeys/options', [PasskeyController, 'registrationOptions']).middleware('auth')
r.post('/passkeys', [PasskeyController, 'register']).middleware('auth')
r.post('/passkeys/login/options', [PasskeyController, 'loginOptions'])
r.post('/passkeys/login', [PasskeyController, 'login']).middleware('throttle:10,60')registrationOptions() excludes the user's existing passkeys, so the same authenticator is not registered twice. Pass { authenticatorAttachment: 'platform' } to prefer the built-in authenticator. verifyRegistration() throws a 400 when the challenge has expired or was already used, the origin or RP ID is wrong, or the credential is already registered.
Signing in
authenticationOptions(ctx) without a user allows any discoverable passkey; authenticationOptions(ctx, { user }) limits the options to that user's passkeys (for a "type your email first" flow).
Passkeys.login(ctx, response, { guard? })verifies the response, logs the user in through a session guard and returns the user.Passkeys.verifyAuthentication(ctx, response)only verifies and returns{ user, credential }. Use it for JWT or token APIs and issue the token yourself:
ts
const { user } = await Passkeys.verifyAuthentication(ctx, await ctx.body())
return ctx.auth.jwt().login(user)Failures throw a 401. Each successful sign-in updates the credential's signature counter, backedUp flag and lastUsedAt.
Managing passkeys
ts
await Passkeys.credentials(user) // PasskeyCredential[]
await Passkeys.rename(user, id, 'MacBook') // false if not the user's
await Passkeys.delete(user, id) // false if not the user'sA PasskeyCredential has id, name, transports, deviceType ('multiDevice' for synced passkeys such as iCloud Keychain or Google Password Manager), backedUp, aaguid, createdAt and lastUsedAt. The public key is stored, never a secret.
The browser side
Use @simplewebauthn/browser; hazejs does not ship a client:
ts
import { startAuthentication, startRegistration } from '@simplewebauthn/browser'
// register (logged in)
const optionsJSON = await api.post('/passkeys/options')
const response = await startRegistration({ optionsJSON })
await api.post('/passkeys', { response, name: 'My laptop' })
// sign in
const loginOptions = await api.post('/passkeys/login/options')
await api.post('/passkeys/login', await startAuthentication({ optionsJSON: loginOptions }))With the session challenge store, send the options and verification requests with cookies (same origin, or credentials: 'include'). The full-stack templates enable csrf, so include the X-XSRF-TOKEN header as for other POST requests.
Custom stores
ts
import type { PasskeyStore, TwoFactorStore } from '@hazejs/core'
const twoFactor: TwoFactorStore = {
get: async (userType, userId) => { /* TwoFactorRecord | undefined */ },
set: async (userType, userId, record) => { /* insert or replace */ },
delete: async (userType, userId) => { /* … */ },
}
const passkeys: PasskeyStore = {
create: async (credential) => { /* … */ },
find: async (id) => { /* … */ },
list: async (userType, userId) => { /* … */ },
update: async (id, values) => { /* name, counter, backedUp, lastUsedAt */ },
delete: async (id) => { /* return whether it existed */ },
}userType is the provider name and userId the user id as a string, as with API tokens.