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
| enabled | enforce | Behavior |
|---|---|---|
| false | — | 2FA is entirely off. No setup screen, no verification. Convenient for local development. |
| true | false | Optional. Eligible users are prompted at login but can choose "Skip for now." Anyone who opts in verifies on every login. |
| true | true | Required. 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
- User authenticates with username and password. The full session is not established — only a pending member id is held.
- They are redirected to
/auth/twofasetup, which renders a QR code as inline SVG (BaconQrCode, no external image service, nothing leaves the server). - They scan it with any TOTP app — Google Authenticator, Authy, 1Password, Bitwarden. Standard
otpauth://, no proprietary app. - They enter a 6-digit code to prove enrollment worked.
- Ten single-use recovery codes are displayed once, formatted
XXXX-XXXX-XXXX. - 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_TESTINGenvironment variable and a connection from127.0.0.1. It checksREMOTE_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.
- 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_DURATIONfor 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_LEVELSor 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.