Two-factor authentication is one of those features that everybody agrees is important and almost nobody adds to an internal tool, because "important" and "worth two days of work right now" are different judgments. So it stays on the list, and the admin panel — the account with the most power in the system — stays behind a single password.

In Tiknix, TOTP two-factor is already built. Turning it on is a line in a config file, and the interesting design question becomes what policy do I want rather than how do I implement this.

Three states, two flags

; conf/config.ini
[security]
two_factor_enabled = true   ; master switch
two_factor_enforce = true   ; false = optional, true = required
enabledenforceBehavior
false2FA is entirely off. No setup screen, no verification. Convenient for local development.
truefalseOptional. Eligible users are prompted at login but can choose "Skip for now." Anyone who opts in verifies on every login.
truetrueRequired. Eligible users must enroll before they can proceed. This is the default and the secure setting.

"Optional" is genuinely useful and often skipped in implementations that treat 2FA as a binary. It lets you roll out to a team without locking anyone out on a Monday morning: the prompt appears, the willing enroll, the skip is session-scoped so it asks again next time, and you flip enforce to true once adoption is where you want it.

Who it applies to

// lib/TwoFactorAuth.php
public const REQUIRED_LEVELS = [1, 50];           // ROOT, ADMIN
public const TRUST_DURATION  = 30 * 24 * 60 * 60; // 30 days
public const RECOVERY_CODE_COUNT = 10;

By default 2FA is scoped to the accounts that can do damage — root and admin — plus users with workbench access, since those can trigger code execution. Ordinary members are not prompted. That is a defensible default: protect privilege, don't tax everyone. Widening it to all members is a one-line change to REQUIRED_LEVELS.

Enforcement funnels through two functions — needsSetup() and needsVerification() — called from the login path. Two choke points, not scattered checks, which is what makes the policy auditable.

The enrollment flow

  1. User authenticates with username and password. The full session is not established — only a pending member id is held.
  2. They are redirected to /auth/twofasetup, which renders a QR code as inline SVG (BaconQrCode, no external image service, nothing leaves the server).
  3. They scan it with any TOTP app — Google Authenticator, Authy, 1Password, Bitwarden. Standard otpauth://, no proprietary app.
  4. They enter a 6-digit code to prove enrollment worked.
  5. Ten single-use recovery codes are displayed once, formatted XXXX-XXXX-XXXX.
  6. The device is trusted for 30 days and login completes.

Subsequent logins from an untrusted device land on /auth/twofaverify, which takes either a TOTP code or a recovery code.

Details that matter

Codes are verified with drift tolerance

return self::getGoogle2FA()->verifyKey($secret, $code, 1);

One period of tolerance either side — about ±30 seconds. Without it, a phone whose clock is slightly off produces valid codes the server rejects, which generates support tickets and, worse, trains users to believe 2FA is flaky.

Recovery codes are hashed and consumed

Recovery codes are stored as password_hash() digests, not plaintext — a database read does not hand over a working bypass. Verification normalizes case and dashes (so a user typing a1b2c3d4e5f6 in lowercase succeeds), and a matched code is removed from the stored set immediately and the remaining count logged. Single-use means single-use.

Device trust is a signed token, not a flag

$payload   = $memberId . ':' . $expiry;
$signature = hash_hmac('sha256', $payload, self::getTrustSecret());
return base64_encode($payload) . '.' . $signature;

The trust token carries its own expiry and is HMAC-signed, so a tampered payload fails validation. Comparison uses hash_equals() — timing-safe, which is the kind of detail that separates a real implementation from a plausible one. The client stores it; the client cannot forge it.

Bypasses are narrow and honest about it

Two escape hatches exist, and both are deliberately hard to abuse:

  • Testing bypass requires the TIKNIX_TESTING environment variable and a connection from 127.0.0.1. It checks REMOTE_ADDR, the actual TCP peer, not a forwarded header a client could set.
  • IP whitelist is an admin-configured CIDR list, off unless explicitly enabled, and also matched against REMOTE_ADDR.

Both are documented in the source with the reasoning for using REMOTE_ADDR spelled out. A bypass you can read and reason about is much safer than one that is undocumented.

Why this belongs in the framework rather than in your backlog

Every part of the flow above is a place a rushed implementation goes wrong: plaintext recovery codes, no drift tolerance, an unsigned "trusted device" cookie, a bypass that trusts X-Forwarded-For, recovery codes that stay valid after use. None of these are exotic mistakes — they are the normal outcome of writing 2FA under deadline pressure.

The value of inheriting it is that the decisions were made once, by someone reading the details, and are now the same in every application built on the framework.

Honest notes
  • TOTP only. No WebAuthn, no passkeys, no SMS (SMS is a feature to lack), no push. TOTP is the right 80% answer; hardware keys are a real gap if you need phishing-resistant auth.
  • 30-day device trust is a real trade-off. It buys adoption at the cost of a month-long window on a stolen, unlocked laptop. Shorten TRUST_DURATION for high-sensitivity deployments.
  • Recovery codes are shown exactly once. If a user does not save them and loses their authenticator, an admin has to intervene. Have an account-recovery procedure written down before you enforce 2FA.
  • The trust secret is a real secret. Trust tokens are only as strong as the signing key behind them. Keep [security] out of version control and rotate it if it leaks — rotation invalidates all trusted devices, which is the correct behavior.
  • Members are not covered by default. If your app holds customer data that customers care about, extend REQUIRED_LEVELS or run in optional mode so members can opt in.
  • Enabling 2FA changes your support load. New phone, wiped app, lost codes — these arrive as tickets. Budget for it; it is still worth it.