docs/07-users/02-multi-factor-authentication.md
import Aside from "@components/Aside.astro" import AutoScreenshot from "@components/AutoScreenshot.astro"
Users in Filament can sign in with their email address and password by default. However, you can enable multi-factor authentication (MFA) to add an extra layer of security to your users' accounts.
When MFA is enabled, users must perform an extra step before they are authenticated and have access to the application.
<AutoScreenshot name="panels/mfa-challenge" alt="The multi-factor authentication challenge page" version="5.x" />Filament includes two methods of MFA which you can enable out of the box:
In Filament, users set up multi-factor authentication from their profile page. If you use Filament's profile page feature, setting up multi-factor authentication will automatically add the correct UI elements to the profile page:
use Filament\Panel;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->profile();
}
To enable app authentication in a panel, you must first add a new column to your users table (or whichever table is being used for your "authenticatable" Eloquent model in this panel). The column needs to store the secret key used to generate and verify the time-based one-time passwords. It can be a normal text() column in a migration:
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
Schema::table('users', function (Blueprint $table) {
$table->text('app_authentication_secret')->nullable();
});
In the User model, you should implement the HasAppAuthentication interface and use the InteractsWithAppAuthentication trait which provides the necessary methods to interact with the secret code and other information about the integration:
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthentication;
use Filament\Auth\MultiFactor\App\Concerns\InteractsWithAppAuthentication;
use Filament\Models\Contracts\FilamentUser;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable implements FilamentUser, HasAppAuthentication, MustVerifyEmail
{
use InteractsWithAppAuthentication;
// ...
}
Finally, you should activate the app authentication feature in your panel. To do this, use the multiFactorAuthentication() method in the configuration, and pass a AppAuthentication instance to it:
use Filament\Auth\MultiFactor\App\AppAuthentication;
use Filament\Panel;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->multiFactorAuthentication([
AppAuthentication::make(),
]);
}
If your users lose access to their two-factor authentication app, they will be unable to sign in to your application. To prevent this, you can generate a set of recovery codes that users can use to sign in if they lose access to their two-factor authentication app.
In a similar way to the app_authentication_secret column, you should add a new column to your users table (or whichever table is being used for your "authenticatable" Eloquent model in this panel). The column needs to store the recovery codes. It can be a normal text() column in a migration:
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
Schema::table('users', function (Blueprint $table) {
$table->text('app_authentication_recovery_codes')->nullable();
});
Next, you should implement the HasAppAuthenticationRecovery interface on the User model and use the InteractsWithAppAuthenticationRecovery trait which provides Filament with the necessary methods to interact with the recovery codes:
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthentication;
use Filament\Auth\MultiFactor\App\Concerns\InteractsWithAppAuthentication;
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthenticationRecovery;
use Filament\Auth\MultiFactor\App\Concerns\InteractsWithAppAuthenticationRecovery;
use Filament\Models\Contracts\FilamentUser;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable implements FilamentUser, HasAppAuthentication, HasAppAuthenticationRecovery, MustVerifyEmail
{
use InteractsWithAppAuthentication;
use InteractsWithAppAuthenticationRecovery;
// ...
}
Finally, you should activate the app authentication recovery codes feature in your panel. To do this, pass the recoverable() method to the AppAuthentication instance in the multiFactorAuthentication() method in the configuration:
use Filament\Auth\MultiFactor\App\AppAuthentication;
use Filament\Panel;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->multiFactorAuthentication([
AppAuthentication::make()
->recoverable(),
]);
}
By default, Filament generates 8 recovery codes for each user. If you want to change this, you can use the recoveryCodeCount() method on the AppAuthentication instance in the multiFactorAuthentication() method in the configuration:
use Filament\Auth\MultiFactor\App\AppAuthentication;
use Filament\Panel;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->multiFactorAuthentication([
AppAuthentication::make()
->recoverable()
->recoveryCodeCount(10),
]);
}
By default, users can visit their profile to regenerate their recovery codes. If you want to prevent this, you can use the regenerableRecoveryCodes(false) method on the AppAuthentication instance in the multiFactorAuthentication() method in the configuration:
use Filament\Auth\MultiFactor\App\AppAuthentication;
use Filament\Panel;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->multiFactorAuthentication([
AppAuthentication::make()
->recoverable()
->regenerableRecoveryCodes(false),
]);
}
App codes are issued using a time-based one-time password (TOTP) algorithm, which means that they are only valid for a short period of time before and after the time they are generated. The time is defined in a "window" of time. By default, Filament uses an expiration window of 8, which creates a 4-minute validity period on either side of the generation time (8 minutes in total).
To change the window, for example to only be valid for 2 minutes after it is generated, you can use the codeWindow() method on the AppAuthentication instance, set to 4:
use Filament\Auth\MultiFactor\App\AppAuthentication;
use Filament\Panel;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->multiFactorAuthentication([
AppAuthentication::make()
->codeWindow(4),
]);
}
Each app authentication integration has a "brand name" that is displayed in the authentication app. By default, this is the name of your app. If you want to change this, you can use the brandName() method on the AppAuthentication instance in the multiFactorAuthentication() method in the configuration:
use Filament\Auth\MultiFactor\App\AppAuthentication;
use Filament\Panel;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->multiFactorAuthentication([
AppAuthentication::make()
->brandName('Filament Demo'),
]);
}
Email authentication sends the user one-time codes to their email address, which they must enter to verify their identity.
To enable email authentication in a panel, you must first add a new column to your users table (or whichever table is being used for your "authenticatable" Eloquent model in this panel). The column needs to store a boolean indicating whether or not email authentication is enabled:
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
Schema::table('users', function (Blueprint $table) {
$table->boolean('has_email_authentication')->default(false);
});
Next, you should implement the HasEmailAuthentication interface on the User model and use the InteractsWithEmailAuthentication trait which provides Filament with the necessary methods to interact with the column that indicates whether or not email authentication is enabled:
use Filament\Auth\MultiFactor\Email\Contracts\HasEmailAuthentication;
use Filament\Auth\MultiFactor\Email\Concerns\InteractsWithEmailAuthentication;
use Filament\Models\Contracts\FilamentUser;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable implements FilamentUser, HasEmailAuthentication, MustVerifyEmail
{
use InteractsWithEmailAuthentication;
// ...
}
Finally, you should activate the email authentication feature in your panel. To do this, use the multiFactorAuthentication() method in the configuration, and pass an EmailAuthentication instance to it:
use Filament\Auth\MultiFactor\Email\EmailAuthentication;
use Filament\Panel;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->multiFactorAuthentication([
EmailAuthentication::make(),
]);
}
Email codes are issued with a lifetime of 4 minutes, after which they expire.
To change the expiration period, for example to only be valid for 2 minutes after codes are generated, you can use the codeExpiryMinutes() method on the EmailAuthentication instance, set to 2:
use Filament\Auth\MultiFactor\Email\EmailAuthentication;
use Filament\Panel;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->multiFactorAuthentication([
EmailAuthentication::make()
->codeExpiryMinutes(2),
]);
}
You can add another MFA method by creating an object that implements the MultiFactorAuthenticationProvider interface. The provider tells Filament how to identify the method, determine whether it is enabled for a user, manage it, and validate its login challenge.
The following sections use an SMS authentication provider as an example. The provider delegates code generation, storage, delivery, and verification to an SmsAuthenticationService in your app. This keeps the provider focused on integrating your authentication method with Filament:
<?php
namespace App\Filament\Auth\MultiFactor;
use App\Services\SmsAuthenticationService;
use Filament\Auth\MultiFactor\Contracts\MultiFactorAuthenticationProvider;
class SmsAuthentication implements MultiFactorAuthenticationProvider
{
public function __construct(
protected SmsAuthenticationService $service,
) {}
public static function make(): static
{
return app(static::class);
}
// ...
}
The service should generate codes using a cryptographically secure random source, store only a hash of each code, scope codes to the user they were issued for, expire and consume codes, and rate-limit both delivery and verification attempts. It may deliver codes using any SMS notification channel supported by Laravel.
The getId() method must return a stable identifier that is unique among the panel's MFA providers. Filament uses it to identify the provider and scope its form state. The getLoginFormLabel() method returns the option shown when a user has more than one MFA method enabled:
// ...
public function getId(): string
{
return 'sms';
}
public function getLoginFormLabel(): string
{
return 'SMS';
}
// ...
The isEnabled() method determines whether a user should be challenged by the provider. For example, you could store a has_sms_authentication boolean and a phone_number on the User model:
use App\Models\User;
use Illuminate\Contracts\Auth\Authenticatable;
// ...
public function isEnabled(Authenticatable $user): bool
{
if (! ($user instanceof User)) {
return false;
}
return filled($user->phone_number) && ((bool) $user->has_sms_authentication);
}
// ...
The user passed to isEnabled() is not authenticated yet when Filament is preparing a login challenge, so you should always use the method's $user argument instead of the currently authenticated user.
The getManagementSchemaComponents() method returns the schema components and actions used to manage the provider. Filament renders them on the user's profile page and, when MFA is required, on the required MFA setup page:
use App\Filament\Auth\MultiFactor\Actions\DisableSmsAuthenticationAction;
use App\Filament\Auth\MultiFactor\Actions\SetUpSmsAuthenticationAction;
use Filament\Schemas\Components\Actions;
// ...
public function getManagementSchemaComponents(): array
{
return [
Actions::make([
SetUpSmsAuthenticationAction::make($this->service),
DisableSmsAuthenticationAction::make($this->service),
]),
];
}
// ...
In this example, the setup and disable actions should send an SMS code, display a OneTimeCodeInput, verify the code using the service, and then persist the new enabled state. Keeping these workflows in separate action classes prevents the provider from becoming difficult to read. If your integration manages enrollment elsewhere, the management schema could instead contain an action that links to that page.
The getChallengeFormComponents() method returns the fields shown after the user's password has been verified. Filament completes authentication only when the components pass validation, so the SMS code field uses the service to reject an invalid challenge:
use Closure;
use Filament\Forms\Components\OneTimeCodeInput;
use Illuminate\Contracts\Auth\Authenticatable;
use SensitiveParameter;
// ...
public function getChallengeFormComponents(Authenticatable $user): array
{
return [
OneTimeCodeInput::make('code')
->label('SMS code')
->required()
->rule(fn (): Closure => function (string $attribute, #[SensitiveParameter] mixed $value, Closure $fail) use ($user): void {
if (is_string($value) && $this->service->verifyCode($user, $value)) {
return;
}
$fail('The SMS code is invalid or has expired.');
}),
];
}
// ...
The verification operation should consume a valid code so that it cannot be used successfully again.
SMS providers need to send a code before displaying the challenge. To run logic at that point, also implement the HasBeforeChallengeHook interface and add the beforeChallenge() method:
use Filament\Auth\MultiFactor\Contracts\HasBeforeChallengeHook;
use Illuminate\Contracts\Auth\Authenticatable;
class SmsAuthentication implements HasBeforeChallengeHook, MultiFactorAuthenticationProvider
{
// ...
public function beforeChallenge(Authenticatable $user): void
{
$this->service->sendCode($user);
}
// ...
}
The beforeChallenge() method may be called more than once if the user switches between enabled providers. The service should rate-limit code delivery and avoid invalidating an existing code when another code cannot be sent yet.
Finally, register the provider with the panel's multiFactorAuthentication() method:
use App\Filament\Auth\MultiFactor\SmsAuthentication;
use Filament\Panel;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->multiFactorAuthentication([
SmsAuthentication::make(),
]);
}
By default, users are not required to set up multi-factor authentication. You can require users to configure it by passing isRequired: true as a parameter to the multiFactorAuthentication() method in the configuration:
use Filament\Auth\MultiFactor\App\AppAuthentication;
use Filament\Panel;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->multiFactorAuthentication([
AppAuthentication::make(),
], isRequired: true);
}
When this is enabled, users will be prompted to set up multi-factor authentication after they sign in, if they have not already done so.
In Filament, the multi-factor authentication process occurs before the user is actually authenticated into the app. This allows you to be sure that no users can authenticate and access the app without passing the multi-factor authentication step. You do not need to remember to add middleware to any of your authenticated routes to ensure that users completed the multi-factor authentication step.
However, if you have other parts of your Laravel app that authenticate users, please bear in mind that they will not be challenged for multi-factor authentication if they are already authenticated elsewhere and then visit the panel, unless multi-factor authentication is required and they have not set it up yet.
When a user signs in with a recovery code, Filament's verifyRecoveryCode() method wraps the read-validate-write sequence in a per-user Cache::lock and a database transaction with a lockForUpdate() row lock on the user's row. The cache lock serializes concurrent submissions across PHP workers regardless of the underlying database driver, so two parallel sign-in requests cannot both consume the same code or resurrect a just-consumed code from a stale snapshot — even when the storage is a non-SQL store, a different database connection, or a driver without SELECT ... FOR UPDATE support (such as SQLite).
If you override `getAppAuthenticationRecoveryCodes()` / `saveAppAuthenticationRecoveryCodes()`, the cache lock still wraps the full read-validate-write sequence, so your override is protected. Your override is only responsible for making the storage write itself atomic — for example, a single Eloquent `update()` or an equivalent atomic primitive on your chosen store.