aymakan / laravel-mfa
API-only Multi-Factor Authentication enforcement for Laravel (TOTP, middleware-driven)
Installs: 6
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/aymakan/laravel-mfa
Requires
- php: ^8.4
- illuminate/auth: ^12.0
- illuminate/contracts: ^12.0
- illuminate/database: ^12.0
- illuminate/http: ^12.0
- illuminate/routing: ^12.0
- illuminate/session: ^12.0
- illuminate/support: ^12.0
- pragmarx/google2fa: ^9.0
Requires (Dev)
- orchestra/testbench: ^10.0
- phpunit/phpunit: ^11.0
README
A production-grade, API-only Multi-Factor Authentication (MFA) enforcement package for Laravel. This package provides a clean, middleware-driven MFA layer that works after successful email/password login.
Features
- TOTP-based MFA using RFC 6238 (compatible with Google Authenticator, Authy, etc.)
- API-only — no Blade views, no redirects; JSON responses only
- Middleware-driven — does not replace Laravel authentication
- Pluggable architecture — ready for future SMS/Email/Push drivers
- Multiple verification stores — session, cache, or database (see Verification state storage)
- Security-first — OTP replay protection, rate limiting, fail-closed design
- Config-driven — fully customizable via published config
- Enforcement options — global (
required/optional) or per-user (e.g.mfa_requiredattribute)
Requirements
- PHP 8.2+
- Laravel 10+ / 11+ / 12+
- Laravel Sanctum (or similar API auth with Bearer token)
Installation
composer require aymakan/laravel-mfa
Publish config and migrations:
php artisan vendor:publish --tag=mfa-config php artisan vendor:publish --tag=mfa-migrations php artisan migrate
Basic setup
1. Add the HasMfa trait to your User model
use Aymakan\Mfa\Traits\HasMfa; class User extends Authenticatable { use HasMfa; // Optional: add to $fillable if using per-user enforcement protected $fillable = ['email', 'password', 'mfa_required']; protected $casts = [ 'mfa_required' => 'boolean', ]; }
2. Apply the middleware to protected routes
// routes/api.php (or your API route file) Route::middleware(['auth:sanctum', 'mfa.verified'])->group(function () { Route::get('/user', [UserController::class, 'show']); Route::get('/dashboard', [DashboardController::class, 'index']); // ... other protected routes });
MFA routes (status, verify, enroll) are not protected by mfa.verified; they only require auth. Keep your main app routes behind mfa.verified.
API endpoints
The package registers these routes under the configured prefix (default: mfa, so full paths are e.g. /mfa/status):
| Method | Path | Description |
|---|---|---|
| GET | /{prefix}/status |
MFA status for authenticated user |
| POST | /{prefix}/verify |
Verify MFA code (login flow) |
| POST | /{prefix}/enroll/start |
Start MFA enrollment |
| POST | /{prefix}/enroll/confirm |
Confirm enrollment with OTP |
| DELETE | /{prefix}/enroll/cancel |
Cancel pending enrollment |
| DELETE | /{prefix}/disable |
Disable MFA (requires OTP) |
/verify and /enroll/confirm and /disable use the throttle:mfa rate limiter.
Usage examples
Check MFA status
GET /mfa/status Authorization: Bearer {token}
Response:
{
"data": {
"mfa_enabled": true,
"mfa_verified": true,
"mfa_type": "totp",
"mfa_required": true,
"verified_at": "2026-02-02T12:39:42+00:00"
}
}
verified_at is present only when mfa_verified is true.
Enroll in MFA
Step 1: Start enrollment
POST /mfa/enroll/start Authorization: Bearer {token}
Response:
{
"data": {
"provisioning_uri": "otpauth://totp/MyApp:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=MyApp",
"type": "totp",
"message": "Scan the QR code with your authenticator app, then confirm with a code."
}
}
Step 2: Generate a QR code from provisioning_uri (e.g. with a frontend library or https://api.qrserver.com).
Step 3: Confirm enrollment
POST /mfa/enroll/confirm Authorization: Bearer {token} Content-Type: application/json {"code": "123456"}
Response:
{
"data": {
"enabled": true,
"message": "MFA has been enabled successfully."
}
}
Verify MFA (login flow)
After login, if protected routes return 403 with MFA_REQUIRED, call verify with the user’s OTP:
POST /mfa/verify Authorization: Bearer {token} Content-Type: application/json {"code": "123456"}
Response:
{
"data": {
"verified": true,
"message": "MFA verification successful."
}
}
Disable MFA
DELETE /mfa/disable Authorization: Bearer {token} Content-Type: application/json {"code": "123456"}
Response:
{
"data": {
"disabled": true,
"message": "MFA has been disabled."
}
}
Middleware behaviour
When mfa.verified is applied:
- If MFA is globally disabled (
enabled= false) → pass through. - If the user is not authenticated → pass through (let auth middleware handle it).
- Enforcement:
- If the user must complete MFA (
enforcement=requiredor per-user attribute e.g.mfa_required= true): they must have MFA enabled and verified; otherwise → 403MFA_REQUIRED. - If MFA is optional for this user: only users who already have MFA enabled must verify; others pass.
- If the user must complete MFA (
- If the user has MFA enabled and is verified → pass through.
- If the user has MFA enabled but not verified → 403
MFA_REQUIRED.
The mfa_required field in GET /mfa/status indicates whether this user must complete MFA (enroll and/or verify).
Error response when MFA is required but not satisfied:
{
"error": {
"code": "MFA_REQUIRED",
"message": "Multi-factor authentication is required."
}
}
Verification state storage
Verification state (“this user has passed MFA for this session / period”) can be stored in three ways:
| Store | Config value | Use when |
|---|---|---|
| Session | session (default) |
Frontend sends session cookies (stateful Sanctum). Verification lives for the session. |
| Cache | cache |
Bearer-only API; no session. Verification is keyed by user id and TTL (e.g. 8 hours). |
| Database | database |
Bearer-only API; survives cache flush. Uses mfa_verifications table and TTL. |
Set in config:
'verification' => [ 'store' => env('MFA_VERIFICATION_STORE', 'session'), // 'session' | 'cache' | 'database' // Session (when store === 'session') 'session_key' => 'mfa_verified_at', 'lifetime' => null, // null = session lifetime // Cache (when store === 'cache') 'key_prefix' => 'mfa_verified_at', 'lifetime' => 480, // minutes (e.g. 8 hours) // Database (when store === 'database'); run mfa migrations 'table' => 'mfa_verifications', 'lifetime' => 480, ],
For SPAs using only Bearer tokens (no session cookies), use cache or database so verification persists across requests.
Configuration options
// config/mfa.php return [ 'enabled' => env('MFA_ENABLED', true), // 'optional' = only users who have MFA enabled must verify // 'required' = every user must enroll and verify 'enforcement' => env('MFA_ENFORCEMENT', 'optional'), // Per-user attribute (e.g. mfa_required). When true, that user must complete MFA // even when enforcement is 'optional'. Set to null to disable. 'enforcement_per_user_attribute' => env('MFA_ENFORCEMENT_PER_USER_ATTRIBUTE'), 'default' => env('MFA_DRIVER', 'totp'), 'drivers' => [ 'totp' => [ 'issuer' => env('MFA_TOTP_ISSUER', config('app.name')), 'digits' => 6, 'period' => 30, 'algorithm' => 'sha1', 'window' => 1, ], ], 'routes' => [ 'prefix' => 'mfa', 'middleware' => ['api', 'auth:sanctum'], ], 'middleware_alias' => 'mfa.verified', 'errors' => [ 'mfa_required' => [ 'status' => 403, 'code' => 'MFA_REQUIRED', 'message' => 'Multi-factor authentication is required.', ], 'mfa_invalid' => [ 'status' => 422, 'code' => 'MFA_INVALID', 'message' => 'Invalid verification code.', ], 'mfa_rate_limited' => [ 'status' => 429, 'code' => 'MFA_RATE_LIMITED', 'message' => 'Too many verification attempts. Please try again later.', ], ], 'rate_limit' => [ 'max_attempts' => 5, 'decay_minutes' => 5, ], 'verification' => [ 'store' => env('MFA_VERIFICATION_STORE', 'session'), 'session_key' => 'mfa_verified_at', 'lifetime' => null, 'key_prefix' => 'mfa_verified_at', 'table' => 'mfa_verifications', ], 'user' => [ 'model' => null, // or \App\Models\User::class 'foreign_key' => 'user_id', ], ];
Using the facade
use Aymakan\Mfa\Facades\Mfa; $hasMfa = Mfa::userHasMfaEnabled($user); $valid = Mfa::verify($user, '123456'); $uri = Mfa::getProvisioningUri($user); Mfa::disable($user);
Security
- No OTP reuse — replay protection for verification attempts.
- Rate limiting —
throttle:mfaon verify, confirm, and disable (configurable inrate_limit). - Fail closed — if MFA state is unclear, access is denied.
- Logout — verification state is cleared on
Illuminate\Auth\Events\Logout.
Testing
composer test
License
MIT License