Two-Factor Authentication (2FA/TOTP) Implementation on Website
Two-factor authentication via TOTP (Time-based One-Time Password) — a second factor as a 6-digit code from an authenticator app (Google Authenticator, Authy, 1Password, etc.). Codes are generated using the RFC 6238 algorithm: HMAC-SHA1 from current time and secret key, updated every 30 seconds.
Technology Stack
- Algorithm: TOTP (RFC 6238), HOTP (RFC 4226)
- PHP library:
pragmarx/google2faorsonata-project/google-authenticator - QR Code:
bacon/bacon-qr-code - Storage: encrypted secret key in database
2FA Activation Workflow
1. User clicks "Enable 2FA"
2. Server generates secret (base32, 160 bits)
3. Server returns QR code and backup codes
4. User scans QR in authenticator app
5. User enters first code for confirmation
6. Server saves secret as active
Secret Generation and QR Code
composer require pragmarx/google2fa bacon/bacon-qr-code
use PragmaRX\Google2FA\Google2FA;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
class TwoFactorSetupController extends Controller
{
public function setup(Request $request): JsonResponse
{
$google2fa = new Google2FA();
$secret = $google2fa->generateSecretKey(32);
// Store temporarily in session until confirmation
session(['2fa_pending_secret' => $secret]);
$otpauthUrl = $google2fa->getQRCodeUrl(
config('app.name'),
$request->user()->email,
$secret
);
$renderer = new ImageRenderer(
new RendererStyle(200),
new SvgImageBackEnd()
);
$writer = new Writer($renderer);
$qrSvg = $writer->writeString($otpauthUrl);
return response()->json([
'secret' => $secret,
'qr_code' => base64_encode($qrSvg),
'qr_uri' => $otpauthUrl,
]);
}
}
Confirmation and Activation
public function confirm(Request $request): JsonResponse
{
$request->validate(['code' => 'required|digits:6']);
$secret = session('2fa_pending_secret');
$google2fa = new Google2FA();
if (!$google2fa->verifyKey($secret, $request->code)) {
return response()->json(['message' => 'Invalid code'], 422);
}
// Save encrypted secret
$request->user()->update([
'two_factor_secret' => encrypt($secret),
'two_factor_enabled_at' => now(),
]);
// Generate recovery codes
$recoveryCodes = $this->generateRecoveryCodes();
$request->user()->update([
'two_factor_recovery_codes' => encrypt(json_encode($recoveryCodes)),
]);
session()->forget('2fa_pending_secret');
return response()->json(['recovery_codes' => $recoveryCodes]);
}
private function generateRecoveryCodes(): array
{
return collect(range(1, 8))->map(fn() =>
Str::random(5) . '-' . Str::random(5)
)->toArray();
}
Middleware for 2FA Verification
After basic authentication, users with enabled 2FA must pass the second factor:
class TwoFactorMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user && $user->two_factor_enabled_at && !session('2fa_verified')) {
return redirect()->route('2fa.challenge');
}
return $next($request);
}
}
class TwoFactorChallengeController extends Controller
{
public function verify(Request $request): RedirectResponse
{
$request->validate(['code' => 'required|string']);
$user = $request->user();
$google2fa = new Google2FA();
$secret = decrypt($user->two_factor_secret);
$code = $request->code;
// Verify TOTP code
$validTotp = $google2fa->verifyKey($secret, $code, 1); // window=1 (±30 sec)
// If not TOTP — check recovery code
if (!$validTotp) {
$recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true);
if (!in_array($code, $recoveryCodes, true)) {
return back()->withErrors(['code' => 'Invalid code']);
}
// Remove used recovery code
$remaining = array_filter($recoveryCodes, fn($c) => $c !== $code);
$user->update([
'two_factor_recovery_codes' => encrypt(json_encode(array_values($remaining))),
]);
}
session(['2fa_verified' => true]);
return redirect()->intended('/dashboard');
}
}
Protection Against Code Reuse
TOTP codes are valid for 30 seconds. To prevent reuse of the same code:
// Store hash of last used code
$lastUsed = Cache::get("2fa_last_used:{$user->id}");
$currentHash = hash('sha256', $secret . $code . floor(time() / 30));
if ($lastUsed === $currentHash) {
return response()->json(['message' => 'Code already used'], 422);
}
Cache::put("2fa_last_used:{$user->id}", $currentHash, 60);
Recovery Codes
Eight single-use codes in the format xxxxx-xxxxx. Displayed once during setup — user must save them. When using a recovery code, remove it from list. When fewer than 2 codes remain, notify user to generate new ones.
Timeline
| Stage | Time |
|---|---|
| Secret generation + QR code | 1 day |
| Confirmation + saving | 0.5 day |
| Middleware + challenge page | 1 day |
| Recovery codes + reuse prevention | 0.5 day |
| UI (React/Vue) + tests | 1.5 days |
Total: 4–5 working days.







