Skip to content

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',
  },
}))
OptionDefaultDescription
providerdefault guard's providerWhere users come from
storein memorydatabaseTwoFactorStore() or any TwoFactorStore
issuerapp.nameService name shown in the authenticator app
account'email'User field used as the account label
digits / period / algorithm6 / 30 / 'SHA1'Code parameters. Most apps only support the defaults
window1Accepted clock drift, in time steps (±30 seconds)
recoveryCodes8Number of recovery codes
challengeTtl'5m'How long a login waits for the code
maxAttempts5Wrong 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
cacheStoredefault cache storeCache store (from config/cache.ts) that keeps the attempt counters

What is stored:

  • the secret, encrypted with APP_KEY (the same AES-GCM encryption as Crypt);
  • 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 ​

MethodDescription
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/server

It 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',
  },
}))
OptionDefaultDescription
providerdefault guard's providerWhere users come from
storein memorydatabasePasskeyStore() or any PasskeyStore
rpNameapp.nameName shown by the browser
rpIDhost of APP_URL, else of the requestThe domain passkeys are bound to (no scheme or port)
originorigin of APP_URL, else of the requestAccepted origin(s), e.g. ['https://example.com', 'https://app.example.com']
challenge'session'Where challenges are kept: 'session', or 'cache' for APIs without sessions
cacheStoredefault cache storeCache store name when challenge: 'cache'
timeout60000Milliseconds 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's

A 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.

Released under the MIT License.