Password and TOTP

The four factors axess-factors ships by default (password, totp, hotp, email_otp) are the ones most adopters reach for first. They require no external IdP, no specialised hardware, no extra infrastructure. Password (Argon2id) and TOTP (RFC 6238) are the common pairing, and the pattern they illustrate generalises to every other factor in the library.

The feature flags password, totp, hotp, email_otp are all on by default in axess-factors. No Cargo.toml change is needed to use them.

Password (Argon2id)

The password factor verifies a user-supplied secret against a stored Argon2id hash. The choice of Argon2id rather than bcrypt or PBKDF2 is the standard one for new systems built today; the parameter tuning is the operational lever you reach for first.

The configuration struct is PasswordConfig, and it is smaller than you might expect:

pub struct PasswordConfig {
    /// Argon2id PHC hash string, zeroized on drop.
    pub hash: ZeroizedString,
    /// Strength rules applied when setting a new password.
    pub rules: PasswordRules,
}

There are no Argon2 parameters to set, and no pepper. Hashing is the password_auth crate's generate_password_hash / verify_password pair, which picks recommended Argon2id parameters, generates a fresh random salt per hash, and encodes the parameter set into the stored hash itself, in PHC string format (the Password Hashing Competition's $argon2id$v=19$m=... encoding). Verification reads the parameters back out of the stored hash, so raising the cost later is a matter of upgrading the crate and rehashing on next login. Old hashes keep verifying against the parameters they were made with.

That also means the cost is not yours to tune from axess, and the answer to "can I use a pepper" is that you would have to apply it yourself before calling generate_password_hash, storing the result in hash. Nothing in the type stops you; nothing in the type helps you either.

What is configurable is the strength rules:

pub struct PasswordRules {
    pub min_length: usize,        // default 12
    pub require_uppercase: bool,  // default true
    pub require_lowercase: bool,  // default true
    pub require_digit: bool,      // default true
    pub require_special: bool,    // default false
    pub history_count: usize,     // default 0, no reuse check
}

So axess does enforce complexity, and the defaults are stricter than the habitual eight-character minimum: twelve characters with upper, lower and a digit. require_special is off by default because the character-class requirement that most reliably produces Password1! is the one that demands punctuation.

history_count is the reuse check, and it is off by default because it costs something to turn on: a non-zero value makes the flow call IdentityPasswordHistory::password_history and record_password_hash. That trait has no default bodies, so the password flow is bounded on it and a store that does not implement it will not compile against the flow. Set history_count to 12 for the SOC2-shaped "cannot reuse the last twelve" rule, and implement the trait at the same time.

Rules are resolved per tenant, through IdentityLookup::password_rules_for_tenant, which defaults to PasswordRules::default(). A deployment with one policy can ignore it; a deployment that sells a stricter tier can override it per tenant without touching the login path.

There is no maximum length in the rules. You should want one, because Argon2id is deliberately expensive and an unbounded password field is a cheap way to burn server CPU. Impose it at the edge, where the request is parsed, before the value reaches the hasher.

TOTP (RFC 6238)

The TOTP factor verifies a six-digit code derived from a shared secret and the current time window. The shared secret is twenty bytes of cryptographic randomness, generated at enrolment time and stored alongside the user's other factor configurations.

The configuration struct is TotpConfig:

pub struct TotpConfig {
    pub secret: ZeroizedString,      // raw bytes; base32 for provisioning URIs
    pub digits: u8,                  // default 6
    pub period_secs: u32,            // default 30
    pub algorithm: OtpAlgorithm,     // Sha1 | Sha256 | Sha512, default Sha1
    pub past_window: u32,            // default 1
    pub future_window: u32,          // default 1
    pub last_step: Option<u64>,      // last validated counter; blocks replay
}

Two fields there are not decoration. The drift window is two numbers, not one: past_window for a client behind the server and future_window for one ahead of it. Both default to 1, because a phone's clock runs ahead as often as behind. NTP-synced devices drift forward across time-zone changes, and a handset's OS clock is commonly a few hundred milliseconds early, so a one-sided window rejects valid codes from those users.

last_step is the replay defence. It records the counter of the last code accepted, so a code that already worked cannot be used again inside its remaining validity. That makes TotpConfig mutable state, not just configuration: your factor store must persist the updated value after a successful verification, or the same intercepted code stays usable for the rest of its window.

secret is zeroized in memory on drop. It holds the raw bytes, twenty cryptographically random ones from SecureRng; base32 is the encoding applied when the secret goes into a provisioning URI for a QR code or a manual key, not how it is stored. Adopters serialise it to and from their factor store however the store's encryption envelope prefers.

digits is six in line with every TOTP authenticator in production use. RFC 6238 admits up to eight, but no widely deployed TOTP app generates eight-digit codes, so the field exists for symmetry rather than for variability.

period_secs is the time window each code is valid for. Thirty is the RFC default and what every authenticator app expects. Increasing the period (to sixty seconds, say) reduces the chance that a user typing slowly enters a code that has just expired, at the cost of doubling the window an intercepted code remains valid. The recommendation is to keep this at thirty unless you have a specific reason to change it.

algorithm is an OtpAlgorithm: Sha1, Sha256 or Sha512. SHA-1 is the RFC 6238 default and the only one guaranteed to interoperate. Most modern authenticator apps handle SHA-256; few handle SHA-512. Stay on SHA-1 unless you control which app the users will use.

past_window and future_window count the adjacent time steps the verifier accepts on each side. One and one, against a thirty-second period, gives a ninety-second total acceptance range. Lifting either reduces friction for users with a drifting clock at the cost of widening the window an intercepted code stays usable, and widening past_window in particular gives a brute-force attempt more valid targets per guess. The defaults are the right trade for most deployments.

Composing password and TOTP

A method that combines password and TOTP is two FactorSteps:

use axess::{FactorKind, FactorStep, Method};

let password_plus_totp = Method {
    name: "password-then-totp".into(),
    steps: vec![
        FactorStep::Required(FactorKind::Password),
        FactorStep::Required(FactorKind::Totp),
    ],
};

The method is stored at whatever scope the deployment wants (Global default, Tenant override, User override; see Scope hierarchy). At begin_login time the resolver loads the method, the session transitions to Authenticating with remaining = [Password, Totp], and the login flow walks the two factors in order.

The application's login page renders the password prompt while the session is in Authenticating with remaining[0] == Password, and the TOTP prompt while in Authenticating with remaining[0] == Totp. A successful TOTP verification calls advance_factor, which returns Completed, and the orchestrator transitions the session to Authenticated. The user is logged in.

A common variant offers TOTP plus another second factor as a choice:

let password_plus_2fa_choice = Method {
    name: "password-then-2fa-choice".into(),
    steps: vec![
        FactorStep::Required(FactorKind::Password),
        FactorStep::AnyOf(vec![
            FactorKind::Totp,
            FactorKind::Fido2,
            FactorKind::EmailOtp,
        ]),
    ],
};

The login page after the password step shows three options. The user picks one; the application calls verify_factor with the appropriate credential; on success, the session is authenticated.

TOTP enrolment

Enrolment is a separate ceremony from login. The user is already authenticated (often immediately after signup), and the application walks them through registering a TOTP device. The shape is uniform across deployments.

The server generates a new TOTP secret through SecureRng. It serialises the secret as a base32 string and as an otpauth://totp/<issuer>:<account>?secret=<base32>&issuer=<issuer> URI suitable for embedding in a QR code. The UI displays the QR code (scanned by the user's TOTP app) and offers a copy of the base32 secret for users whose apps prefer manual entry.

The user enters a six-digit code from their app, the server verifies it against the same TOTP algorithm that login uses, and on success the server persists the secret to the factor store under the user's scope. The user is now enrolled. Their next login that demands TOTP will succeed.

Two operational details matter at enrolment.

The first is that the verification at enrolment must succeed before the secret is persisted. A user who scans the QR code but mistypes the verification code (or scans into the wrong app) should not be left with a stored secret that they cannot reproduce. The standard pattern is: generate the secret in memory, display the QR code, hold the secret in a short-lived enrolment record (in the session custom field, for example), verify the user's code, persist on success, discard on failure.

The second is recovery codes. A user who loses access to their TOTP device cannot log in with a method that requires TOTP. The deployment must offer a recovery path: either a recovery code printed at enrolment time (a long random string the user stores in a password manager), an email-OTP fallback factor, or an administrative reset flow with identity verification. Axess does not opinionate which path to take; the choice depends on the deployment's risk profile. The common pattern is to generate a recovery code at enrolment, treat it as a one-shot factor stored under the user's scope, and offer it as an alternative second factor.

HOTP and email OTP, briefly

The HOTP factor is the counter-based variant of TOTP. Instead of deriving the code from the current time window, the verifier derives it from a monotonically-increasing counter that advances on every successful verification. HOTP is the right choice for hardware tokens that have no clock (some YubiKey configurations, for instance). The configuration mirrors TotpConfig with a counter field instead of a period.

The email OTP factor verifies a six-digit code delivered to the user out of band, typically by email. The configuration carries the code length, the validity window (default fifteen minutes), and the count of allowed attempts before the code is revoked. The delivery is the application's responsibility; axess provides the verification side, the application provides the email send. The chapter Audit events covers the events emitted at email-OTP issuance and verification.

Threat model

A password-plus-TOTP login is robust against three common attacks and weak against one.

It is robust against either credential leaking on its own. The password alone does not complete a login without the TOTP code, and the TOTP secret alone does not complete one without the password. That also covers credential stuffing: an attacker replaying credentials leaked from another service is unlikely to hold the user's TOTP secret as well.

It is weak against a real-time phishing attack: a fake login page that prompts the user for their password, forwards it to the real server, prompts the user for their TOTP code, forwards that to the real server, and steals the resulting session. FIDO2 (covered in FIDO2 and WebAuthn passkeys) is the standard defence against this class of attack, because the WebAuthn ceremony binds the authentication to the origin and cannot be replayed against a different origin.

For applications where real-time phishing is a credible threat (financial services, healthcare, anything that handles regulated data), the recommendation is to offer FIDO2 as the second factor and treat TOTP as a fallback for users who do not yet have a passkey. The combination is what most regulators are asking for today.

Troubleshooting

A few failures recur often enough to be worth naming.

If TOTP verification fails consistently, the most likely cause is clock skew between the server and the authenticator app. The drift_window config accommodates a few seconds; larger drift points to a misconfigured NTP setup on either side. Logging the generated and accepted windows at debug level surfaces the offset quickly.

If TOTP verification fails for some users but not others, the likely cause is that the affected users scanned the QR code into an app that defaults to SHA-256 (some less common authenticators do), while the server defaults to SHA-1. The fix is to either align the server to SHA-256 (and re-enrol users), or to ensure the QR code URI explicitly specifies SHA-1.

If password verification is slow under load, the Argon2id parameters are probably set higher than the server class can support at the offered concurrency. The fix is to either lower the memory cost or to add CPU. Lower the memory cost first; below sixty-four megabytes you are out of the modern recommendation, and sixty-four megabytes is what current guidance suggests as a minimum.

If password verification is fast but logins occasionally take multiple seconds, the bottleneck is somewhere else (the factor store, the session store, an outbound network call in the login handler). Inspect the trace.

Further reading

Factors and methods covers the composition machinery this chapter exercises. FIDO2 and WebAuthn passkeys covers the WebAuthn second factor that supplants TOTP for the highest-assurance deployments. Identity store implementation covers how the password hash and TOTP secret are persisted alongside the user. Audit events covers the events emitted at every step of the password and TOTP flow.