Implementing 2FA in Laravel: A Practical Approach
Recently, I needed to add two-factor authentication to a privileged access management system I'm actively developing. My objective was simple: add a 2FA feature that's reliable and secure.
When considering 2FA options, there are typically three main approaches: SMS, Email, and TOTP (Time-based One-Time Password). Initially, before I started on this feature, my go-to would probably have been SMS or Email since they're straightforward to implement. SDKs are readily available from providers like Twilio, SES, SNS, Nexmo, Mailgun, and others.
But right before I started building, I sat down to reconsider the options. I had previously omitted TOTP because I hadn't developed it before. After doing some research, I realized it's actually not that difficult at all. The process is straightforward: generate a secret → generate QR code based on secret and user info → user scans QR → verify OTP → done. The biggest benefit is avoiding reliance on external vendors and services.
We've all encountered scenarios where waiting for OTP via SMS or Email results in rejection or delays due to third-party issues. SMS is particularly problematic - your phone and line need stable network coverage, which might not be available in places like basements. Even with stable network, I've experienced cases where delivery gets delayed on the telco's end.
Another edge case I personally encountered in previous employment was a cyber attack targeting our SMS OTP services. The attack was simple but effective: bombard our login API with OTP requests to foreign numbers. It managed to bring our login service down because our Nexmo account ran out of credit. The entire attack cost us over $1,000 USD. With SMS or Email-based 2FA, which costs money per trigger, you have to take extra precautions to avoid similar attacks. With TOTP, as long as users have their authenticator device (typically their mobile phone), they can access and enter the OTP without network limitations and no additional cost to the service provider.
When I started researching Laravel TOTP implementations, I found that most articles didn't quite match what I was looking for - especially for API-first architectures. After working through the implementation, I thought I'd share the approach I've been developing in case it helps others facing similar challenges.
Library Choice
I went with pragmarx/google2fa-qrcode
for this implementation. It's well-maintained, handles the heavy lifting for TOTP generation and verification, and includes QR code support out of the box.
{
"require": {
"pragmarx/google2fa-qrcode": "^3.0",
"bacon/bacon-qr-code": "^3.0"
}
}
Database Structure
The key insight I learned was to separate the concept of "enabled" from "confirmed." This prevents users from accidentally locking themselves out during setup.
// Migration
Schema::table('users', function (Blueprint $table) {
$table->boolean('two_factor_enabled')->default(false);
$table->timestamp('two_factor_enabled_at')->nullable();
$table->text('two_factor_secret')->nullable();
$table->text('two_factor_recovery_codes')->nullable();
$table->timestamp('two_factor_confirmed_at')->nullable();
});
User Model Implementation
I added the 2FA logic directly to the User model to keep things organized:
class User extends Authenticatable
{
protected $casts = [
'two_factor_enabled' => 'boolean',
'two_factor_enabled_at' => 'datetime',
'two_factor_confirmed_at' => 'datetime',
'two_factor_secret' => 'encrypted',
];
public function generateTwoFactorSecret(): string
{
return (new Google2FA)->generateSecretKey();
}
public function verifyTwoFactorCode(string $code): bool
{
return (new Google2FA)->verifyKey($this->two_factor_secret, $code);
}
public function isTwoFactorConfirmed(): bool
{
return $this->two_factor_confirmed_at !== null;
}
public function twoFactorQrCode(): Attribute
{
return Attribute::make(
get: function () {
if (!$this->isTwoFactorEnabled() || $this->isTwoFactorConfirmed()) {
return null;
}
$appName = config('app.name');
$inlineQr = (new Google2FA)->getQRCodeInline(
$appName,
$this->email,
$this->two_factor_secret
);
return 'data:image/svg+xml;base64,' . base64_encode($inlineQr);
}
);
}
}
Authentication Flow
Here's where I had to think through the user experience. For users with 2FA enabled, I needed a way to verify their identity in two steps without maintaining server-side sessions.
// AuthController.php
public function login(LoginRequest $request): JsonResponse
{
$attempt = Auth::attemptWhen(
$request->only('email', 'password'),
fn(User $user) => $user->isActive()
);
if (!$attempt) {
return $this->unauthorized('Invalid credentials.');
}
$user = User::where('email', $request->email)->first();
if ($user->isTwoFactorConfirmed()) {
// Generate temporary key for 2FA verification
$tempKey = Str::random(32);
$expiresAt = now()->addMinutes(5);
Cache::put(
CacheKey::AUTH_2FA_TEMP_KEY->key($tempKey),
$user->id,
$expiresAt
);
return $this->success([
'requires_2fa' => true,
'temp_key' => $tempKey,
'temp_key_expires_at' => $expiresAt,
]);
}
// Standard login for non-2FA users
$token = $user->createToken('auth_token')->plainTextToken;
return $this->success(['token' => $token]);
}
2FA Verification
The verification step validates both the temporary key and the TOTP code:
public function verifyTwoFactor(Request $request): JsonResponse
{
$validated = $request->validate([
'temp_key' => ['required', 'string', 'size:32'],
'code' => ['required', 'string', 'size:6', 'regex:/^[0-9]{6}$/'],
]);
$userId = Cache::get(
CacheKey::AUTH_2FA_TEMP_KEY->key($validated['temp_key'])
);
if (!$userId) {
return $this->unauthorized('Invalid or expired temp key.');
}
$user = User::find($userId);
if (!$user->verifyTwoFactorCode($validated['code'])) {
return $this->unauthorized('Invalid 2FA code.');
}
// Clean up temp key
Cache::forget(CacheKey::AUTH_2FA_TEMP_KEY->key($validated['temp_key']));
$token = $user->createToken('auth_token')->plainTextToken;
return $this->success(['token' => $token]);
}
What I Learned
Through this implementation, a few patterns emerged that I found particularly useful:
Separation of Concerns: Keeping setup, confirmation, and verification as distinct steps made the flow much clearer to reason about.
Time-bounded State: Using cache with expiration for temporary keys keeps the system stateless while providing security.
Built-in Security: Laravel's encrypted casting handled the sensitive data encryption without additional complexity.
User Experience: The enable/confirm pattern prevents users from accidentally locking themselves out during setup.
Handling Multi-Step Authentication
One of the trickier aspects of 2FA implementation is managing the multi-step login flow. The core problem is: after validating username/password, how do you handle the delay before issuing the final authentication token?
The user journey looks like this:
- User submits credentials
- If 2FA is enabled, don't issue the auth token yet
- Present 2FA challenge
- Only after 2FA verification, issue the final token
I considered several approaches for managing this intermediate state:
Database Storage: Store a temporary record with user ID and expiration. Clean, persistent, but adds database overhead for what's essentially ephemeral data.
Session-Based: Use Laravel sessions to track the intermediate state. Works well for traditional web apps, but my use case was a full API backend serving mobile apps and SPAs.
Temporary Sanctum Token: Issue a limited-scope token that only allows 2FA verification. Elegant, but requires additional middleware and token scope management.
Cache-Based: Store the intermediate state in Redis/Memcached with automatic expiration. This is what I chose.
For my API-first architecture, the cache approach felt most appropriate. Here's why:
- Stateless: No database writes for temporary data
- Auto-cleanup: Redis expiration handles cleanup automatically
- Performance: Fast lookups without database queries
- Scalable: Works well with multiple app instances
- Simple: Minimal code complexity
The cache method aligns well with API design principles where you want to avoid maintaining server-side state between requests.
Future Improvements
While the current implementation works well, there are a few enhancements I'm considering:
Admin-Enforced 2FA: Adding the ability for administrators to enforce 2FA for specific user roles. This would involve:
- A
two_factor_required
field on roles or user profiles - Middleware to check enforcement during login
- Grace period handling for users who need to set up 2FA
Role-Based Access Control: Implementing RBAC to control who can enable/disable/remove 2FA settings. Some users might need restrictions on disabling their 2FA, while others might need administrative oversight.
These additions would make the system more enterprise-ready while maintaining the core simplicity.
Development Notes
I'm still refining this implementation as I work through different scenarios and edge cases. The temporary key approach has been working well during development, and the five-minute expiration window seems like a reasonable balance between security and usability.
The main lesson I'm learning is that good security implementations should feel invisible to users – they just work. The complexity is in the implementation details, not the user experience.