๐ TOTP Two-Factor Authenticationยถ
Time-based One-Time Passwords (TOTP) add a powerful second layer of security to your application. After entering their password, users must provide a 6-digit code from an authenticator app such as Google Authenticator, Authy, or 1Password.
๐ Table of Contentsยถ
How It Worksยถ
User enters email + password
โ
Credentials verified โ
โ
System detects Totp2FA action is required
โ
User is shown a "Enter your 6-digit code" form
โ
User opens authenticator app โ copies code
โ
Code verified against TOTP secret โ
โ
Session created โ user is logged in
The TOTP secret is stored permanently in auth_users_identities with type totp_secret, AES-256 encrypted using CI4โs service('encrypter'). The raw secret is never stored in plain text.
Enrollment follows a two-phase flow:
Pending (
name = totp_pending) โ secret generated, QR shown, user not yet confirmedConfirmed (
name = totp) โ first code verified, TOTP fully active
The Totp2FA login action only challenges users whose TOTP is in the confirmed state.
Configurationยถ
1. Enable the TOTP Post-Login Actionยถ
In app/Config/Auth.php:
use Daycry\Auth\Authentication\Actions\Totp2FA;
public array $actions = [
'register' => null,
'login' => Totp2FA::class, // Require TOTP on every login
];
Note: This only applies to users who have TOTP enabled (
hasTotpEnabled() === true). Users who have not enrolled skip the 2FA step and log in directly.
Only one
loginaction is supported at a time.Totp2FAandWebauthn2FA(passkey second factor) are therefore mutually exclusive as the login second factor โ pick one. See WebAuthn / Passkeys.
2. Set the Issuer Nameยถ
In app/Config/AuthSecurity.php, set the app name shown in the authenticator app:
public string $totpIssuer = 'My App';
// Number of 30-second steps to accept on either side of the current
// timestamp when verifying codes. 1 = ยฑ30s window (RFC 6238 default,
// recommended). Increase only to tolerate severe clock drift; lowering
// to 0 means clients must be perfectly in sync.
public int $totpWindow = 1;
3. Configure the Encryption Keyยถ
TOTP secrets are encrypted with CI4โs encrypter. Make sure app/Config/Encryption.php has a key set (or encryption.key in .env):
# .env
encryption.key = hex2bin:your64charhexstringhere
User Enrollmentยถ
The enrollment flow is handled by the HasTotp trait (mixed into User). It is a two-phase process:
Phase 1 โ Generate the QR codeยถ
$user = auth()->user();
// Always generates a fresh secret (replaces any previous pending one).
// Returns the otpauth:// URI for building a QR code.
$otpAuthUrl = $user->enableTotp('My App');
// getTotpSecret() transparently decrypts the stored value.
$secret = $user->getTotpSecret();
// Build a QR code data URI (rendered locally, no external service).
$qrCodeDataUri = \Daycry\Auth\Libraries\TOTP::getQRCodeUrl($otpAuthUrl);
// $qrCodeDataUri = "data:image/png;base64,..."
return view('security/totp_setup', [
'qrCodeDataUri' => $qrCodeDataUri,
'secret' => $secret, // plain-text fallback for manual entry
]);
enableTotp()stores the secret in the pending state. If the user navigates away before confirming, a fresh secret is generated the next time they visit the setup page.
Phase 2 โ Confirm the first codeยถ
$user = auth()->user();
$code = $this->request->getPost('token');
if (! $user->verifyTotpCode($code)) {
return redirect()->back()->with('error', 'Invalid code. Please try again.');
}
// Upgrades the secret from PENDING โ CONFIRMED. TOTP is now active.
$user->confirmTotp();
return redirect()->to('security')->with('message', 'Two-factor authentication is now enabled.');
Setup Viewยถ
<!-- Phase 1: Show QR code -->
<h2>Enable Two-Factor Authentication</h2>
<h5>Step 1: Scan this QR Code</h5>
<p>Open your authenticator app and scan:</p>
<!-- QR code is a data URI โ no external service required -->
<img src="<?= esc($qrCodeDataUri) ?>" alt="TOTP QR Code" width="200" height="200">
<p class="text-muted mt-2">
Can't scan? Enter this code manually: <code><?= esc($secret) ?></code>
</p>
<!-- Phase 2: Confirm the code -->
<h5>Step 2: Enter the 6-digit code from your app</h5>
<?= form_open(url_to('totp-setup-confirm')) ?>
<input type="text" name="token" maxlength="6" placeholder="000000"
autocomplete="one-time-code" required>
<button type="submit">Confirm & Enable</button>
<?= form_close() ?>
Login Flowยถ
Once TOTP is confirmed for a user, the flow is handled automatically by the Totp2FA action. No changes to LoginController are needed.
What Happens Automaticallyยถ
User submits login form (email + password)
Session::check()verifies credentialsSession detects the
Totp2FAaction is configuredUser is redirected to the action show page (not logged in yet)
The built-in view asks for the 6-digit code
Totp2FA::verify()first checks the per-user lockout (isLockedOut()); if the account is locked, the form is redisplayed with the lockout message and no code is checkedOtherwise it validates the code (TOTP or a backup code). A wrong code counts a failed attempt (
recordFailedAttempt()) โ repeated failures lock the account exactly like password failuresOn success,
resetOnSuccess()clears the failure counter, thencompleteLogin()clears the pending action state and creates the session
The second factor is now rate-limited. Before this change, an attacker who had already passed the password step could brute-force the 6-digit code indefinitely. TOTP and backup-code verification now share the same per-user lockout as password login โ see Brute-Force Lockout & Anti-Replay.
Override the Default TOTP Viewsยถ
In app/Config/Auth.php:
public array $views = [
// ... other views ...
'action_totp_setup_show' => '\Daycry\Auth\Views\totp_setup_show', // QR setup page
'action_totp_setup_success' => '\Daycry\Auth\Views\totp_setup_success', // Confirmation page
'action_totp_show' => '\Daycry\Auth\Views\totp_show', // Login 2FA prompt
'action_totp_verify' => '\Daycry\Auth\Views\totp_verify', // Login 2FA form
'security_overview' => '\Daycry\Auth\Views\profile\security', // User security dashboard
];
HasTotp Trait Referenceยถ
The User entity uses the HasTotp trait, which provides:
// === Enrollment ===
// Generate a new secret (pending), returns the otpauth:// URL.
// If called again, replaces any existing secret.
$user->enableTotp(?string $issuer = null): string
// Returns true while the secret is generated but not yet confirmed.
$user->hasTotpPending(): bool
// Returns true only after confirmTotp() has been called.
$user->hasTotpEnabled(): bool
// Upgrades the identity from PENDING to CONFIRMED.
// Call only after verifyTotpCode() returns true.
$user->confirmTotp(): void
// Returns the decrypted base32 secret (or null if not set).
$user->getTotpSecret(): ?string
// === Verification ===
// Checks a 6-digit code against the user's stored secret.
// $window defaults to AuthSecurity::$totpWindow when null.
// Enforces single-use (anti-replay): a code whose matched time-step
// has already been consumed is rejected even if still inside the window.
$user->verifyTotpCode(string $code, ?int $window = null): bool
// === Backup codes ===
// Replaces the user's backup codes with a fresh set and returns the
// plain-text codes (only shown once โ display them to the user immediately).
$user->generateBackupCodes(int $count = 10): array
// Counts unused backup codes remaining for the user.
$user->backupCodesRemaining(): int
// Verifies + atomically consumes a single backup code.
$user->consumeBackupCode(string $code): bool
// === Removal ===
// Removes the TOTP secret identity AND purges any remaining backup codes.
$user->disableTotp(): void
Security Dashboard Exampleยถ
public function securityIndex(): string
{
$user = auth()->user();
return view('security/index', [
'totpEnabled' => $user->hasTotpEnabled(),
'totpPending' => $user->hasTotpPending(),
'deviceCount' => count($user->getDeviceSessions()),
]);
}
Brute-Force Lockout & Anti-Replayยถ
The second factor is a 6-digit number โ only one million possibilities. Without protection, an attacker who has already passed the password step could simply guess codes in a loop. Two independent mechanisms close that gap.
1. Per-user lockout on the second factorยถ
Totp2FA::verify() reuses the same per-user lockout that guards password login (the UserLockoutManager service). This applies to both TOTP codes and backup codes โ every wrong submission on the 2FA form counts toward the same threshold:
On each request,
isLockedOut($user)is checked first. If the account is locked, the 2FA form is redisplayed withlang('Auth.userLockedOut', [$minutesLeft])and the submitted code is not evaluated.A wrong TOTP/backup code calls
recordFailedAttempt($user), which atomically incrementsusers.failed_login_count.Once the count reaches
userMaxAttempts, the account is locked foruserLockoutTimeseconds (users.locked_untilis set).A correct code calls
resetOnSuccess($user), which clears the counter and unlock timestamp.
This is the exact same flow as password failures โ a failed code and a failed password both advance the one per-user counter, and the lockout is shared.
|
Default |
Meaning |
|---|---|---|
|
|
Maximum consecutive failures (password or 2FA) before the account is locked. |
|
|
Seconds the account stays locked after the threshold is reached. |
// app/Config/AuthSecurity.php
public int $userMaxAttempts = 5; // lock after 5 failed attempts
public int $userLockoutTime = 3600; // stay locked for 1 hour
Note: Setting
$userMaxAttempts = 0disables the lockout for both password and 2FA verification. Leave it at a sensible value (the default5) in production.
2. TOTP codes are single-use within their window (anti-replay)ยถ
A TOTP code is valid for the whole acceptance window (totpWindow steps either side of โnowโ). Within that window the same code would normally verify multiple times โ a replay risk if a code is intercepted. verifyTotpCode() now makes each code single-use:
TOTP::verifyAndGetTimestep()returns the time-step counter that matched (ornullwhen nothing in the window matches).TOTP::verify()is a thin wrapper over it and behaves exactly as before.The last consumed time-step is persisted in the TOTP secret identityโs
extraJSON column as{"last_timestep": <n>}.A code whose matched time-step is less than or equal to the stored
last_timestepis rejected โ so the same code (and any older code still inside the window) can no longer be replayed.
User submits code "123456"
โ
TOTP::verifyAndGetTimestep(...) โ 58432109 (matched time-step)
โ
last_timestep stored on identity = 58432108
โ
58432109 > 58432108 โ accepted, last_timestep updated to 58432109
โ
User (or attacker) replays "123456" within the same window
โ
TOTP::verifyAndGetTimestep(...) โ 58432109
โ
58432109 <= 58432109 (stored) โ REJECTED (already consumed)
Backup codes remain single-use as well, enforced separately by marking the consumed rowโs
used_at(see Backup Codes). The anti-replay above applies specifically to time-based TOTP codes.
Backup Codesยถ
Backup codes let a user authenticate when their authenticator app is unavailable (lost phone, replaced device). They are one-time use โ once consumed, the code cannot be reused.
When they are generatedยถ
UserSecurityController::totpSetupConfirm() calls $user->generateBackupCodes() automatically right after the user confirms their first TOTP code. The plain-text codes are passed once to the success view (Views/totp_setup_success.php) โ store them, screenshot them, or print them. They cannot be retrieved later.
How they work during loginยถ
Totp2FA::verifyCodeForUser() first attempts to verify the input as a TOTP code. If that fails, it tries to consume a backup code:
User submits "abc123def4"
โ
TOTP::verify(...) โ false (not a 6-digit code)
โ
$user->consumeBackupCode('abc123def4')
โ
hash('sha256', 'abc123def4') matches an unused row โ success
โ
Row marked used_at = NOW() โ cannot be used again
The 10 codes are 10-character lowercase hex strings โ visually distinct from the 6-digit TOTP, so accidental collisions are essentially impossible.
Storageยถ
Column |
Description |
|---|---|
|
Standard. |
|
SHA-256 of the lowercase plain code. The plaintext never enters the database. |
|
Datetime of consumption. Null = unused. |
|
Generation timestamp. |
Indexes: (user_id, used_at) for fast unused-code lookups; UNIQUE(user_id, code_hash) to prevent duplicates.
Programmatic regenerationยถ
If a user thinks their backup codes are compromised:
$newCodes = $user->generateBackupCodes(10);
return view('account/new_backup_codes', ['codes' => $newCodes]);
generateBackupCodes() always replaces the entire set โ old codes (used or not) are deleted before the new ones are inserted.
Lifecycle with TOTPยถ
Action |
Effect on backup codes |
|---|---|
|
No effect (codes only generated on |
|
Caller (typically |
|
All codes are purged automatically. |
|
All codes are purged automatically. |
Trust This Deviceยถ
Lets the user opt out of repeating 2FA on devices they own. Combines with Device Sessions โ the trust flag is stored on the auth_device_sessions row, not in a stand-alone cookie payload.
Enableยถ
// app/Config/AuthSecurity.php
// 30 days is a reasonable default. 0 = feature disabled (always require 2FA).
public int $trustedDeviceLifetime = 30 * DAY;
User flowยถ
User logs in with email + password.
Totp2FA::createIdentity()checks for anauth_trusted_devicecookie. If the cookie maps to an activedevice_sessionsrow whosetrusted_untilis in the future and whoseuser_idmatches, the 2FA challenge is skipped entirely.Otherwise the standard 2FA form is shown โ with a โTrust this device for 30 daysโ checkbox if
trustedDeviceLifetime > 0.After successful verification, if the checkbox was ticked:
device_sessions.trusted_until = now + lifetimefor the current session.The
auth_trusted_devicecookie is set with the device UUID encrypted viaservice('encrypter')(HttpOnly, SameSite=Lax, secure whenApp.cookieSecure = true).An
EVENT_TRUSTED_DEVICE_ADDEDaudit entry is recorded.
Revoking trustยถ
Trust is automatically revoked when:
trusted_untilpasses (no longer accepted at login).The user revokes the device session (
UserSecurityController::revokeSession) โlogged_out_atis set, the row no longer matches the trusted-device check.The user logs out manually.
The cookie is deleted by the browser.
To revoke trust programmatically (e.g. when the user changes their password):
/** @var \Daycry\Auth\Models\DeviceSessionModel $devices */
$devices = model(\Daycry\Auth\Models\DeviceSessionModel::class);
foreach ($devices->getAllForUser($user) as $session) {
$devices->revokeTrust((string) $session->uuid);
}
Security propertiesยถ
The cookie carries the device UUID encrypted with the application key. An attacker who steals the cookie alone still needs:
The corresponding active
device_sessionsrow (joined to the sameuser_id).trusted_untilto be in the future.
Stealing only the cookie or only the DB row is not enough.
Revoking the device session immediately invalidates the trust regardless of cookie validity.
When NOT to useยถ
Shared computers / kiosks โ keep
trustedDeviceLifetime = 0.Strict regulatory environments (PCI-DSS Level 1, HIPAA in some interpretations) โ review whether bypassing 2FA per device is acceptable.
UserSecurityController Integrationยถ
Daycry Auth ships with UserSecurityController which provides ready-to-use TOTP management endpoints. Register the routes in app/Config/Routes.php:
$routes->group('security', ['filter' => 'auth:session', 'namespace' => 'Daycry\Auth\Controllers'], static function ($routes) {
$routes->get('/', 'UserSecurityController::index', ['as' => 'security']);
$routes->get('totp/setup', 'UserSecurityController::totpSetup', ['as' => 'totp-setup']);
$routes->post('totp/confirm', 'UserSecurityController::totpSetupConfirm', ['as' => 'totp-setup-confirm']);
$routes->post('totp/disable', 'UserSecurityController::totpDisable', ['as' => 'totp-disable']);
// Device session management
$routes->post('sessions/(:num)/revoke', 'UserSecurityController::revokeSession/$1', ['as' => 'revoke-session']);
$routes->post('sessions/revoke-all', 'UserSecurityController::revokeAllSessions', ['as' => 'revoke-all-sessions']);
});
The views are configured in app/Config/Auth.php under the $views array (see Override the Default TOTP Views above).
Admin TOTP Resetยถ
When a user has lost both their authenticator and all backup codes, an administrator can reset TOTP from the CLI:
php spark auth:totp reset -e alice@example.com
This:
Calls
$user->disableTotp()โ removes the TOTP secret + every backup code.Writes an
EVENT_TOTP_ADMIN_RESETaudit entry withmetadata.initiator = cli.
The user re-enrolls TOTP from scratch the next time they visit /security/totp/setup. See CLI Commands โ auth:totp for full options.
Disabling TOTPยถ
Always require password confirmation before disabling 2FA:
public function disableTotpAction(): RedirectResponse
{
$user = auth()->user();
$password = $this->request->getPost('current_password');
$passwords = service('passwords');
if (! $passwords->verify($password, $user->getPasswordHash())) {
return redirect()->back()->with('error', 'Incorrect password.');
}
$user->disableTotp();
return redirect()->to('security')->with('message', 'Two-factor authentication has been disabled.');
}
Testing TOTPยถ
DatabaseTestCase automatically injects a 32-byte AES encryption key, so service('encrypter') works without any extra setup in your tests.
<?php
namespace Tests\Authentication;
use Tests\Support\DatabaseTestCase;
use Daycry\Auth\Libraries\TOTP;
class TotpTest extends DatabaseTestCase
{
public function testEnrollAndVerifyTotp(): void
{
$user = fake(UserModel::class);
// Phase 1: generate secret (creates a PENDING identity)
$otpAuthUrl = $user->enableTotp('TestApp');
$this->assertStringStartsWith('otpauth://totp/', $otpAuthUrl);
$this->assertTrue($user->hasTotpPending());
$this->assertFalse($user->hasTotpEnabled());
$this->assertNotEmpty($user->getTotpSecret());
// Phase 2: confirm โ TOTP becomes active
$user->confirmTotp();
$this->assertTrue($user->hasTotpEnabled());
$this->assertFalse($user->hasTotpPending());
}
public function testVerifyTotpCode(): void
{
$user = fake(UserModel::class);
$user->enableTotp('TestApp');
$user->confirmTotp();
// An obviously wrong code should fail
$this->assertFalse($user->verifyTotpCode('000000'));
}
public function testDisableTotpRemovesSecret(): void
{
$user = fake(UserModel::class);
$user->enableTotp('TestApp');
$user->confirmTotp();
$user->disableTotp();
$this->assertFalse($user->hasTotpEnabled());
$this->assertNull($user->getTotpSecret());
}
public function testSecretIsEncryptedInDatabase(): void
{
$user = fake(UserModel::class);
$user->enableTotp('TestApp');
/** @var \Daycry\Auth\Models\UserIdentityModel $model */
$model = model(\Daycry\Auth\Models\UserIdentityModel::class);
$identity = $model->where('user_id', $user->id)
->where('type', 'totp_secret')
->first();
// The DB value is base64-encoded ciphertext โ not the raw secret
$this->assertNotSame($user->getTotpSecret(), $identity->secret);
$this->assertNotEmpty(base64_decode($identity->secret, true));
}
}
Security Notesยถ
Always require password confirmation before enabling or disabling TOTP.
The TOTP secret is stored AES-256 encrypted in
auth_users_identities. The raw base32 secret is never in the database in plain text.TOTP codes are valid for a 30-second window (ยฑ
totpWindowsteps tolerance for clock skew). Ensure your server clock is synchronized via NTP.The second factor is brute-force protected. TOTP and backup-code verification share the same per-user lockout as password login (
userMaxAttempts/userLockoutTime). See Brute-Force Lockout & Anti-Replay.TOTP codes are single-use within their acceptance window (anti-replay): once a codeโs time-step is consumed, that code โ and any older code still inside the window โ is rejected. Backup codes are likewise single-use (one-time
used_at).If a user loses access to their authenticator app, they can use a backup code (generated automatically on TOTP confirmation) or an admin reset.
A user with a pending (unconfirmed) TOTP secret is not challenged at login. If they navigate away before confirming, they simply arenโt enrolled yet.
Webauthn2FAis an alternative second factor. A passkey can replace TOTP as the login second factor, but only oneloginaction is supported โTotp2FAandWebauthn2FAare mutually exclusive. See WebAuthn / Passkeys.
๐ See also:
Device Sessions โ Manage trusted devices
Authentication โ All authentication methods
WebAuthn / Passkeys โ Passkey second factor (alternative to TOTP)
Filters โ Protecting routes