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" => "..."}
}secretis shown only during enrollment.otpauth_uriis rendered as QR code.configis 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
end4. 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/createBody:
{
"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.