← Back to blog

Add TOTP MFA and recovery codes with Own Auth


Own Auth treats TOTP as a second factor. A correct password, magic link, SMS code, OAuth callback, Google One Tap credential, or SAML assertion can produce mfa_required, but it does not create a session. The session exists only after a configured TOTP code, recovery code, or passkey completes the challenge.

Configure encryption and MFA

TOTP verification requires the server to recover the shared secret, so Own Auth encrypts it with the configured key ring. Generate a 32 byte base64url key, keep it on the server, and retain previous keys while existing records are reencrypted after successful reads.

auth.ts
import { createOwnAuth } from "own-auth";

export const auth = createOwnAuth({
  tokenPepper: process.env.OWN_AUTH_TOKEN_PEPPER!,
  encryption: {
    current: {
      id: "2026-01",
      key: process.env.OWN_AUTH_ENCRYPTION_KEY!,
    },
  },
  mfa: {
    issuer: "Example",
    challengeTtlMs: 5 * 60 * 1000,
    maxAttempts: 5,
    recoveryCodeCount: 10,
  },
});

Apply npx own-auth migrate before enabling the feature. The MFA migration adds encrypted factor records, recovery code hashes, and short lived challenge records. The encryption key and token pepper solve different problems and should not reuse the same value.

Confirm enrollment before activation

Enrollment starts from an active session. Display the returned URI as a QR code or show the secret for manual entry. Do not mark the factor active until the user supplies a valid code from the authenticator application.

enroll-totp.ts
const enrollment = await auth.beginTotpEnrollment({
  sessionToken,
});

const confirmed = await auth.confirmTotpEnrollment({
  sessionToken,
  factorId: enrollment.factorId,
  code: codeFromAuthenticator,
});

return {
  qrCodeValue: enrollment.uri,
  recoveryCodes: confirmed.recoveryCodes,
};

Recovery codes are returned once after confirmation. Ask the user to save them before leaving the screen. Own Auth stores each code as an independent hash and consumes it once. Never email the complete set or place it in analytics, logs, support tools, or browser storage.

Handle the first factor result

Every sign in route that can return MFA must narrow the result. Treat mfa_required as a successful first factor, not a forbidden request. The direct server API returns a challenge token. The HTTP handler instead keeps that token in a temporary HttpOnly cookie and returns only the methods and expiry to browser JavaScript.

sign-in.ts
const result = await auth.signInEmailPassword({ email, password });

if (result.status === "mfa_required") {
  return {
    status: result.status,
    challengeToken: result.challengeToken,
    methods: result.methods,
    expiresAt: result.expiresAt,
  };
}

return {
  status: result.status,
  sessionToken: result.sessionToken,
  user: {
    id: result.user.id,
    email: result.user.email,
    name: result.user.name,
  },
};

Complete TOTP or recovery

complete-mfa.ts
const completed = await auth.completeMfaWithTotp({
  challengeToken,
  code,
});

if (completed.session.assuranceLevel !== "aal2") {
  throw new Error("Expected an AAL2 session");
}

Use auth.completeMfaWithRecoveryCode for a recovery code. Both methods consume the challenge and create an aal2 session after success. Attempt limits and expiry apply to the challenge. A successfully used TOTP timestep cannot be reused.

Rotate recovery and disable safely

Require the current TOTP code before calling auth.regenerateRecoveryCodes. Regeneration invalidates every previous recovery code. Require the current TOTP code again before auth.disableTotp; disabling also invalidates all recovery codes. A sensitive account may also require recent authentication before either action.

After enrollment, regeneration, or disablement, refresh the browser session view from the server. Do not let stale client state decide whether MFA is enabled. Test sign in before enrollment, confirmed enrollment, an invalid code, challenge expiry, attempt exhaustion, recovery code reuse, regeneration, disablement, and a later sign in after each state change.

Use the browser handler for web applications

Browser code should normally call authClient.beginTotpEnrollment, authClient.confirmTotpEnrollment, and the matching MFA completion methods. The handler reads the current session or pending challenge from HttpOnly cookies, applies origin checks, and never exposes the raw session token or challenge token to JavaScript.

mfa-client.ts
const enrollment = await authClient.beginTotpEnrollment();

const { recoveryCodes } = await authClient.confirmTotpEnrollment({
  factorId: enrollment.factorId,
  code: authenticatorCode,
});

const session = await authClient.completeMfaWithTotp({
  code: challengeCode,
});

Rotate encryption keys without disabling MFA

Add the new encryption key as current and retain the old key under previous. New records use the current key. Existing TOTP secrets can still be decrypted and reencrypted after a successful read. Removing the previous key too early makes those factors unusable and forces account recovery.

Define support and recovery evidence

Support staff should never ask for a live TOTP code, recovery code, QR value, secret, challenge token, or session token. A recovery workflow should use independent verified evidence, record the administrative action, revoke active sessions when compromise is possible, and require the user to enroll a new factor after access is restored.

Monitor challenge completion rates, expiry, and safe error codes without recording the submitted values. A sudden rise in expired challenges can indicate clock, navigation, or delivery problems. Repeated invalid codes remain an abuse signal and should not be turned into account enumeration.

Synchronize server time through the deployment platform and monitor clock drift. TOTP uses fixed time periods and a narrow tolerance, so a server clock problem can reject valid codes for every user. Do not widen the acceptance window as a permanent response because that increases the number of valid codes at any moment.

Operational events may record enrollment started, enrollment confirmed, challenge completed, recovery used, codes regenerated, and factor disabled. Keep the TOTP secret, code, challenge token, recovery codes, session token, and complete request body out of logs and traces.