aymakan/laravel-mfa

API-only Multi-Factor Authentication enforcement for Laravel (TOTP, middleware-driven)

Maintainers

πŸ‘ aymakan

Package info

github.com/aymakan/laravel-mfa

pkg:composer/aymakan/laravel-mfa

Statistics

Installs: 1 041

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.1 2026-02-05 11:44 UTC

Requires (Dev)

Suggests

None

Provides

None

Conflicts

None

Replaces

None

MIT 77d7ce5ff9a70231439a319501deba3127b69f41

AuthenticationmiddlewarelaraveltotpMFA

This package is auto-updated.

Last update: 2026-06-05 12:41:24 UTC


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_required attribute)

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:

  1. If MFA is globally disabled (enabled = false) β†’ pass through.
  2. If the user is not authenticated β†’ pass through (let auth middleware handle it).
  3. Enforcement:
    • If the user must complete MFA (enforcement = required or per-user attribute e.g. mfa_required = true): they must have MFA enabled and verified; otherwise β†’ 403 MFA_REQUIRED.
    • If MFA is optional for this user: only users who already have MFA enabled must verify; others pass.
  4. If the user has MFA enabled and is verified β†’ pass through.
  5. 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:mfa on verify, confirm, and disable (configurable in rate_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