Logotipo Hedhog

Get Hedhog updates in your inbox

New releases, fresh recipes, and breaking changes — no spam.

Multi-Factor Authentication

Hedhog supports three second-factor methods — TOTP (authenticator apps), email one-time codes, and WebAuthn (passkeys/security keys) — plus single-use recovery codes for account recovery. This is a fully implemented feature, built on speakeasy (TOTP), qrcode (QR generation), and @simplewebauthn/server (WebAuthn), all in libraries/core/src/profile/.

Status: ✅ Fully implemented — TOTP, Email OTP, WebAuthn, and recovery codes all work, including the login-challenge flow.

Login challenge token branching into TOTP, Email OTP, and WebAuthn, with recovery codes as the last resort

Quick Setup

  1. Make sure an email provider is configured if you'll allow Email OTP
  2. Enroll TOTP yourself first: POST /profile/totp/generate → scan the QR code → POST /profile/totp/verify
  3. Log out and back in — confirm the response returns requiresMfa: true before any token is issued, and that submitting the code completes login
  4. Save the recovery codes shown at enrollment — there's no way to view them again later

How It Works

Enrolling a method (while already logged in)

All enrollment happens through /profile/* endpoints, scoped to the logged-in user:

StepEndpoint
1. Confirm it's really you before adding a new methodPOST /profile/mfa/check-verificationPOST /profile/mfa/verify-before-add
2a. TOTP: generate a secret + QR codePOST /profile/totp/generate
2a. TOTP: confirm the first 6-digit code to activatePOST /profile/totp/verify
2b. Email OTP: send a code to the account's emailPOST /profile/mfa/email/verify
2b. Email OTP: confirm the code to activatePOST /profile/mfa/email/verify/confirm
2c. WebAuthn: start registration (passkey/security key)POST /profile/webauthn/generate
2c. WebAuthn: confirm the attestationPOST /profile/webauthn/verify

Every enrolled method becomes a row in user_mfa (typetotp, email, webauthn), with the secret/credential stored in a matching user_mfa_totp, user_mfa_email, or user_mfa_webauthn table.

The login challenge (second factor at sign-in)

This is the part that's easy to miss if you only read the enrollment endpoints: login itself does not call any /profile/mfa/* route. Once a password is verified for a user with at least one MFA method enrolled, AuthService returns:

{ "requiresMfa": true, "mfaToken": "..." }

instead of an access token. Your login UI must check for requiresMfa and prompt for the second factor using that short-lived mfaTokennot the user's session:

MethodEndpoint
TOTP or email codePOST /auth/verify-mfa-code with { mfaToken, code, methodType } (methodType: totp, email, or recovery)
Resend the email codePOST /auth/resend-mfa-code with { token: mfaToken }
WebAuthnPOST /auth/webauthn/authenticate/generate with { mfaToken }, then POST /auth/webauthn/authenticate/verify with { mfaToken, assertionResponse }
Recovery code (locked out of every other method)dedicated recovery-code verification endpoint, also keyed by mfaToken

Only after one of these succeeds does the backend issue the real access/refresh token pair.

Settings

Setting keyDescription
require-mfaForce MFA enrollment globally — without this, MFA is opt-in per user
mfa-issuerName shown inside the authenticator app next to the code (defaults to Hedhog)
mfa-windowTolerance window, in 30-second TOTP intervals, to absorb clock drift between server and phone (default 1)
mfa-setpTOTP time-step in seconds (default 30) — yes, it's spelled setp in the real setting slug, not a typo in this doc
mfa-email-code-lengthDigits in the emailed OTP (default 6)
mfa-challenge-expiration-minutesHow long an mfaToken (and any pending email/WebAuthn challenge) stays valid before the user must restart login (default 15)

Configure these in Settings → Configurations → Authentication — MFA settings live in this group, not a dedicated "MFA" one.

TOTP — Authenticator Apps

Works with any RFC 6238 app: Google Authenticator, Authy, Microsoft Authenticator, 1Password, Bitwarden.

  1. Client calls POST /profile/totp/generate — server returns { secret, otpauthUrl, qrCode }
  2. User scans the qrCode (or manually enters secret) in their authenticator app
  3. Client calls POST /profile/totp/verify with the first 6-digit code the app shows, plus the secret and a name for the device
  4. Hedhog validates the code against the secret (honoring mfa-window/mfa-setp) and, only on success, persists the method and issues recovery codes if this is the user's first MFA method

If the code is always rejected even when freshly generated, the most common cause is real clock drift on the server or the user's phone — mfa-window absorbs a little of this, but not unlimited drift.

Email OTP

No app required — works for any user with a verified email.

  1. POST /profile/mfa/email/verify sends a code (length per mfa-email-code-length) to the account's email
  2. POST /profile/mfa/email/verify/confirm activates it once the user enters the code

At login, the equivalent pair is POST /auth/verify-mfa-code (methodType: 'email') and POST /auth/resend-mfa-code if the code expired or never arrived. Email OTP obviously depends on a working email provider — if mail delivery is broken, users enrolled only in email MFA will be locked out of login entirely, not just enrollment.

WebAuthn — Passkeys & Security Keys

Standards-based, phishing-resistant second factor using the browser's native WebAuthn API (Touch ID, Windows Hello, hardware keys like YubiKey).

  1. POST /profile/webauthn/generate returns registration options (name is the label shown for this device later)
  2. The browser's navigator.credentials.create() call uses those options to produce an attestation
  3. POST /profile/webauthn/verify with that attestation completes registration, storing the credential in user_mfa_webauthn

At login: POST /auth/webauthn/authenticate/generate (with mfaToken) returns assertion options for navigator.credentials.get(), and POST /auth/webauthn/authenticate/verify (with mfaToken + the resulting assertionResponse) completes login.

Recovery Codes

Generated automatically the first time a user enrolls any MFA method — 10 single-use codes, shown once, hashed with argon2 in user_recovery_code before storage (Hedhog never stores them in retrievable form, so there's no "view my recovery codes again" feature by design).

EndpointPurpose
POST /profile/recovery-codes/send-verificationPOST /profile/recovery-codes/regenerateInvalidate all old codes and issue a fresh set of 10
DELETE /profile/mfa/:mfaId/remove-with-recovery-codeUse a recovery code to remove a lost MFA method (e.g. phone with the authenticator app was lost) when no other method is available

Tell users explicitly, at enrollment time, that losing both their second factor and their recovery codes means permanent lockout barring manual admin/database intervention — there is no "support resets your MFA" flow built in.

Try this once, in a non-production environment: use a recovery code to log in and confirm it actually works before you depend on it for real.

Removing a Method

Every removal endpoint requires re-proving identity first — you cannot silently strip MFA from a hijacked session:

MethodEndpoint
TOTPDELETE /profile/totp/:mfaId/remove with { token } from check-verification-remove
EmailDELETE /profile/email/:mfaId/remove with { token, hash }
WebAuthnDELETE /profile/webauthn/:mfaId/remove
Any method, via recovery codeDELETE /profile/mfa/:mfaId/remove-with-recovery-code

Verify It Worked

  • Log out and log back in with a user who has MFA enrolled — confirm the response includes requiresMfa: true before any token is issued
  • Submit the second factor and confirm you only now receive accessToken/refreshToken
  • Test one recovery code as described above — don't wait until you're actually locked out to find out it doesn't work

Troubleshooting

SymptomLikely cause
Login response has requiresMfa: true but the frontend shows a generic errorUI isn't handling requiresMfa — it must branch into the second-factor screen instead of treating the response as a failed login
TOTP codes never validateClock drift beyond mfa-window × mfa-setp seconds, or the wrong secret was scanned
User locked out after losing their phoneUse a recovery code via verify-mfa-code (methodType: 'recovery') at login, or remove-with-recovery-code once logged in elsewhere
Email MFA users can't log in at allThe configured email provider is broken — fix that first, this isn't an MFA bug