KMS includes a built-in totp factor provider for authenticator applications such as Google Authenticator, 1Password, Authy, and other RFC 6238-compatible apps.

TOTP is production-usable conventional MFA. It is still phishable because users can type codes into phishing sites. Prefer WebAuthn/passkeys when phishing resistance is required.

Security model

TOTP uses a shared secret seed.

KMS:

  • generates high-entropy Base32 seeds;
  • stores seeds RMK-encrypted in factor config;
  • never returns the seed or current code in session challenges;
  • verifies user-supplied codes during advance_session/2.

The application:

  • owns enrollment UX;
  • displays the QR code during setup;
  • asks the user to confirm one code before saving the factor;
  • must not log seeds, QR URIs, codes, or request bodies.

Enrollment flow

1. Create enrollment data

{:ok, enrollment} =
  KMS.Principals.Factors.TOTP.enrollment("My App", "alice@example.com")

Returned map:

%{
  secret: "BASE32...",
  otpauth_uri: "otpauth://totp/...?secret=BASE32...",
  config: %{"encrypted_secret" => "..."}
}
  • secret is shown only during enrollment.
  • otpauth_uri is rendered as QR code.
  • config is stored on the KMS factor after the user confirms setup.

2. Render QR code

Render enrollment.otpauth_uri as QR code in your application UI.

The user scans it with an authenticator app.

3. Confirm first code

Ask the user for the current code and verify it:

case KMS.Principals.Factors.TOTP.verify_enrollment(enrollment.secret, user_code) do
  :ok -> :confirmed
  {:error, :invalid_factor_code} -> :try_again
end

4. Store factor

Only after confirmation, store the factor:

{:ok, factor} =
  KMS.create_principal_factor("users", "alice", %{
    provider: "totp",
    label: "authenticator app",
    position: 1,
    config: enrollment.config
  })

Do not store the raw secret after enrollment.

Login flow

Create session with password:

{:ok, pending} = KMS.create_session("users", "alice", %{password: password})

Challenge metadata:

%{
  provider: "totp",
  type: "totp",
  label: "authenticator app",
  digits: 6,
  period: 30
}

No code or seed is returned.

Advance with user code:

{:ok, session} = KMS.advance_session(pending.secret, %{code: "123456"})

Defaults

  • 6 digits
  • 30-second period
  • SHA-1 HMAC, matching common authenticator app defaults
  • +/- 1 period verification window
  • 32 random seed bytes before Base32 encoding by default; 20 bytes minimum

Custom options

Enrollment accepts standard TOTP options:

{:ok, enrollment} =
  KMS.Principals.Factors.TOTP.enrollment("My App", "alice@example.com",
    digits: 6,
    period: 30,
    digest: :sha,
    window: 1,
    secret_bytes: 32
  )

enrollment.config includes matching verification options when supplied:

%{
  "encrypted_secret" => "...",
  "digits" => 6,
  "period" => 30,
  "window" => 1,
  "digest" => "sha1"
}

HTTP API

The application should generally generate enrollment data server-side and send only the QR code / setup page to the user.

To store the confirmed factor through HTTP:

POST /api/principal_factors/create

Body:

{
  "kind": "users",
  "ref": "alice",
  "attrs": {
    "provider": "totp",
    "label": "authenticator app",
    "position": 1,
    "config": {"encrypted_secret": "..."}
  }
}

HTTP factor responses omit config.

Operational notes

  • Never log secret, otpauth_uri, user codes, or request bodies.
  • Protect RMK material; it protects encrypted TOTP seeds.
  • Keep password and factor rate limits enabled.
  • TOTP is time-based; keep server clocks synchronized.
  • Treat a leaked TOTP seed as credential compromise and re-enroll the factor.