Laravel API Authentication Using Firebase PHP JWT: Adding Email-Based Multi Factor Authentication
Introduction
In a previous tutorial, we built a comprehensive API authentication system for Laravel applications using Firebase PHP JWT. We successfully implemented a custom JWT guard to authenticate API requests, created a secure login endpoint, and developed a JWT payload builder to ensure consistent token generation. While our existing authentication system provides a solid foundation, modern security practices often require multiple layers of protection.
In this tutorial, we'll enhance our authentication system by implementing email-based two-factor authentication (2FA). In this article, you'll learn how to set up the necessary database schema to support 2FA, generate and verify one-time passwords using the spomky-labs/otphp package, and create a complete authentication flow with temporary tokens and verification endpoints. We'll also refactor our codebase using service classes and data transfer objects to improve maintainability, making it easier to extend our authentication system in the future.
Setting Up Two-Factor Authentication
Let's begin by installing the necessary package for handling one-time passwords:
composer require spomky-labs/otphp
Next, modify the users
database table schema to store 2FA-related information. Create a migration with the following command:
php artisan make:migration add_two_factor_authentication_columns_to_users_table --table=users
In the generated migration file, add the following code to define 2FA-related columns:
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->text('two_factor_secret')->nullable();
$table->json('two_factor_recovery_codes')->nullable();
$table->timestamp('two_factor_disabled_at')->nullable();
$table->string('two_factor_method')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_disabled_at',
'two_factor_method',
]);
});
}
};
With the database migration prepared, apply the changes to the database:
php artisan migrate
Now, update the User
model to incorporate these new 2FA attributes. Add them to the fillable
and casts
properties, and implement some helpful utility methods:
<?php
declare(strict_types=1);
namespace App\Models;
// ... existing namespace imports
use App\Support\Enums\TwoFactorAuthenticationMethod;
class User extends Authenticatable
{
// ... existing properties and methods
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_disabled_at',
'two_factor_method',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
'two_factor_secret',
'two_factor_recovery_codes',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'two_factor_disabled_at' => 'datetime',
'two_factor_method' => TwoFactorAuthenticationMethod::class,
'two_factor_recovery_codes' => 'array',
'two_factor_secret' => 'encrypted',
];
}
public function hasTwoFactorAuthenticationEnabled(): bool
{
return $this->two_factor_disabled_at === null;
}
public function usesTwoFactorAuthApp(): bool
{
return $this->two_factor_method === TwoFactorAuthenticationMethod::AUTHENTICATOR_APP;
}
public function usesTwoFactorEmail(): bool
{
return $this->two_factor_method === TwoFactorAuthenticationMethod::EMAIL;
}
}
The two_factor_secret
is stored as an encrypted value to ensure it remains secure, even if the database is compromised. Unlike passwords, which are hashed and cannot be reversed, the 2FA secret key needs to be decryptable so it can be used to verify OTPs. By using Laravel’s encrypted cast, the framework automatically handles encryption when saving the secret and decryption when accessing it. This prevents the secret from ever being stored in plain text while still allowing it to be used for authentication, aligning with security best practices. Additionally, by including the two_factor_secret
in the model’s hidden properties, we ensure it is excluded from any serialized output, providing an extra layer of protection during API responses or data exports.
To complement the model updates, create the TwoFactorAuthenticationMethod
enum that defines our supported 2FA methods:
<?php
declare(strict_types=1);
namespace App\Support\Enums;
enum TwoFactorAuthenticationMethod
{
case EMAIL;
case AUTHENTICATOR_APP;
}
Refactoring to Services
Before implementing 2FA, let's refactor our code to improve maintainability by moving logic from controllers into service classes. This approach helps keep our controllers lean and focused on three key responsibilities:
- Receiving the request
- Validating the request
- Sending a response to the client
We'll also use data transfer objects instead of arrays to pass data between controllers and service classes. This structure ensures better separation of concerns, making our codebase easier to manage and extend.
Let's start by creating a LoginService
:
touch app/Services/LoginService.php
Now, implement the service with the following code:
<?php
declare(strict_types=1);
namespace App\Services;
use App\Builders\JwtPayloadBuilder;
use App\Guards\JwtGuard;
use App\Models\User;
use App\Support\DataTransferObjects\AuthenticationResponse;
use App\Support\DataTransferObjects\LoginDataTransferObject;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Auth\Events\Authenticated;
use Illuminate\Support\Carbon;
class LoginService
{
public function __construct(private JWTCodec $codec) {}
/**
* @throws \Throwable
*/
public function login(LoginDataTransferObject $data): AuthenticationResponse
{
/** @var JwtGuard $guard */
$guard = auth()->guard('api');
throw_if(
! $guard->validate($data->toArray()),
new AuthenticationException(trans('auth.failed'))
);
/** @var User $user */
$user = $guard->getLastAttempted();
$payload = (new JwtPayloadBuilder($user))
->issuedNow()
->getPayload();
$token = $this->codec->encode($payload);
throw_if(
$token === null,
new AuthenticationException(trans('auth.failed'))
);
$response = new AuthenticationResponse(
$user,
$token,
Carbon::parse($payload['exp']),
);
event(new Authenticated('jwt', $user));
return $response;
}
}
To support our service, we need to create the LoginDataTransferObject
that will encapsulate login credentials:
<?php
declare(strict_types=1);
namespace App\Support\DataTransferObjects;
final class LoginDataTransferObject
{
private string $email;
private string $password;
/**
* @throws \Throwable
*/
public function __construct(string $email, #[\SensitiveParameter] string $password)
{
throw_if(
empty($email),
new \UnexpectedValueException('The email can not be an empty value.')
);
throw_if(
empty($password),
new \UnexpectedValueException('The password can not be an empty value.')
);
$this->email = $email;
$this->password = $password;
}
public function toArray(): array
{
return [
'email' => $this->email,
'password' => $this->password,
];
}
}
The SensitiveParameter
attribute is used to mark a parameter that is sensitive and should have its value redacted if present in a stack trace.
We also need the AuthenticationResponse
data transfer object to structure our API responses:
<?php
namespace App\Support\DataTransferObjects;
use App\Http\Resources\UserResource;
use App\Models\User;
final class AuthenticationResponse
{
public function __construct(
private User $user,
private string $token,
private \DateTimeInterface $expiresAt,
) {}
public function toArray(): array
{
return [
'user' => new UserResource($this->user->withoutRelations()),
'token' => [
'type' => 'Bearer',
'access_token' => $this->token,
'expires_at' => (string) $this->expiresAt->getTimestamp(),
],
];
}
}
With our service and data transfer objects in place, we can now simplify the LoginController
:
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Services\LoginService;
use App\Support\DataTransferObjects\LoginDataTransferObject;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* @throws \Throwable
*/
public function __invoke(Request $request, LoginService $service): JsonResponse
{
$request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
]);
$data = new LoginDataTransferObject($request->input('email'), $request->input('password'));
$response = $service->login($data);
return response()->json(['data' => $response->toArray()], Response::HTTP_OK);
}
Implementing the Login Flow with 2FA
Now, let's enhance our authentication process to incorporate 2FA. This implementation involves:
- Updating the login endpoint to issue temporary JWTs for users with 2FA enabled
- Generating and sending One-Time Passwords (OTPs) via email for users utilizing email-based 2FA
- Creating a new endpoint for 2FA verification
Adjusting Token Lifetimes
First, we need to modify how token lifetimes are managed. Rename the lifetimeInHours
method of the JwtPayloadBuilder
to lifetimeInMinutes
and adjust it to manage token lifetime in minutes:
public function lifetimeInMinutes(int $minutes): self
{
return $this->withClaim('exp', Carbon::now()->addMinutes(abs($minutes))->getTimestamp());
}
Additionally, update the jwt.php
configuration file to define the token lifetime in minutes:
/*
|--------------------------------------------------------------------------
| Token Lifetime Expiry
|--------------------------------------------------------------------------
|
| Here you may define the amount in minutes before authentication tokens
| expire
|
*/
'token_lifetime' => env('JWT_TOKEN_LIFETIME', 60),
Introducing Scope Management
Scopes are a JWT claim that represent specific permissions within a JWT. According to the Token Exchange RFC, the scope
claim should be a space-separated list of permissions. Let's implement methods that will allow us add or remove specific permissions from the JWT payload:
<?php
declare(strict_types=1);
namespace App\Builders;
use App\Support\Enums\JsonWebTokenScope;
class JwtPayloadBuilder
{
// ... existing class properties
private array $scopes = [];
private array $defaultClaims = [
'sub',
'jti',
'iss',
'exp',
];
// ... existing class methods
public function addScope(JsonWebTokenScope $scope): self
{
if ($scope->isInvalid() || in_array($scope->value, $this->scopes, true)) {
return $this;
}
if ($scope === JsonWebTokenScope::TWO_FACTOR_SCOPE && in_array(JsonWebTokenScope::ALL_SCOPES->value, $this->scopes, true)) {
throw new \InvalidArgumentException('Can not add 2FA challenge scope when all scopes are granted.');
}
if ($scope === JsonWebTokenScope::ALL_SCOPES && in_array(JsonWebTokenScope::TWO_FACTOR_SCOPE->value, $this->scopes, true)) {
throw new \InvalidArgumentException('Can not grant all scopes when 2FA challenge scope is present.');
}
if ($scope === JsonWebTokenScope::ALL_SCOPES) {
$this->scopes = [JsonWebTokenScope::ALL_SCOPES->value];
return $this->withClaim('scope', implode(' ', $this->scopes));
}
$this->scopes[] = $scope->value;
return $this->withClaim('scope', implode(' ', $this->scopes));
}
public function removeScope(JsonWebTokenScope $scope): self
{
$this->scopes = array_filter($this->scopes, fn ($element) => $element !== $scope->value);
return empty($this->scopes)
? $this->withoutClaim('scope')
: $this->withClaim('scope', implode(' ', $this->scopes));
}
public function withoutClaim(string $claim): self
{
if (in_array($claim, $this->defaultClaims, true)) {
return $this;
}
unset($this->payload[$claim]);
return $this;
}
}
These new methods provide three key features to our builder:
addScope
assigns a permission to the payload while preventing conflicting scopes (like 2FA and full access) from being mixed and avoids duplicate scopes from beign addedremoveScope
eliminates a specific permission from the payload, removing the entirescope
claim if no permissions remainwithoutClaim
removes any claim from the payload if it's no longer needed as long as it is not a default claim. Defailt claims are claims we always want present in the payload
Next, define an enum to represent our different scopes, ensuring consistent values throughout the application:
<?php
namespace App\Support\Enums;
enum JsonWebTokenScope: string
{
case ALL_SCOPES = '*';
case TWO_FACTOR_SCOPE = '2fa-challenge';
public function isValid(): bool
{
return ! $this->isInvalid();
}
public function isInvalid(): bool
{
return empty($this->value);
}
}
Updating the Login Flow
Now, we need to update our login process to issue temporary JWTs for users with 2FA enabled. Since we've refactored authentication logic into a service class, these changes are straightforward:
<?php
declare(strict_types=1);
namespace App\Services;
use App\Builders\JwtPayloadBuilder;
use App\Guards\JwtGuard;
use App\Models\User;
use App\Events\TwoFactorChallengeInitiated;
use App\Support\DataTransferObjects\AuthenticationResponse;
use App\Support\DataTransferObjects\LoginDataTransferObject;
use App\Support\Enums\JsonWebTokenScope;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Auth\Events\Authenticated;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
class LoginService
{
public function __construct(private JWTCodec $codec) {}
/**
* @throws \Throwable
*/
public function login(LoginDataTransferObject $data): AuthenticationResponse
{
/** @var JwtGuard $guard */
$guard = auth()->guard('api');
throw_if(
! $guard->validate($data->toArray()),
new AuthenticationException(trans('auth.failed'))
);
/** @var User $user */
$user = $guard->getLastAttempted();
$builder = (new JwtPayloadBuilder($user))->issuedNow();
if ($user->hasTwoFactorAuthenticationEnabled()) {
$builder->lifetimeInMinutes(config('jwt.two_factor_token_lifetime'))
->addScope(JsonWebTokenScope::TWO_FACTOR_SCOPE);
} else {
$builder->addScope(JsonWebTokenScope::ALL_SCOPES);
}
$payload = $builder->getPayload();
$payload = $payloadBuilder->getPayload();
$token = $this->codec->encode($payload);
throw_if(
$token === null,
new AuthenticationException(trans('auth.failed'))
);
$response = new AuthenticationResponse(
$user,
$token,
Carbon::parse($payload['exp']),
$user->hasTwoFactorAuthenticationEnabled()
);
$user->hasTwoFactorAuthenticationEnabled()
? event(new TwoFactorChallengeInitiated($user))
: event(new Authenticated('jwt', $user));
return $response;
}
}
Add the two_factor_token_lifetime
configuration entry to the jwt.php
configuration file:
'two_factor_token_lifetime' => env('JWT_TWO_FACTOR_TOKEN_LIFETIME', 15),
Now, update the AuthenticationResponse
to include information about 2FA requirements:
final class AuthenticationResponse
{
public function __construct(
private User $user,
private string $token,
private \DateTimeInterface $expiresAt,
private bool $enforceTwoFactorAuthentication
) {}
public function toArray(): array
{
return [
'user' => $this->enforceTwoFactorAuthentication ? null : new UserResource($this->user->withoutRelations()),
'requires_two_factor_challenge' => $this->enforceTwoFactorAuthentication,
'two_factor_method' => $this->enforceTwoFactorAuthentication ? strtolower($this->user->two_factor_method->name) : null,
'token' => [
'type' => 'Bearer',
'access_token' => $this->token,
'expires_at' => (string) $this->expiresAt->getTimestamp(),
],
];
}
}
Generating and sending OTPs
Let's create the TwoFactorChallengeInitiated
event that is being fired from the LoginService
:
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class TwoFactorChallengeInitiated
{
use Dispatchable;
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public User $user) {}
}
Next, define a listener, HandleTwoFactorChallenge
, to handle OTP generation and user notification:
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Events\TwoFactorChallengeInitiated;
use App\Notifications\TwoFactorChallengeNotification;
use App\Services\TwoFactorAuthenticationService;
use Illuminate\Contracts\Queue\ShouldQueue;
class HandleTwoFactorChallenge implements ShouldQueue
{
/**
* Create the event listener.
*/
public function __construct(private TwoFactorAuthenticationService $twoFactorService) {}
/**
* Handle the event.
*
* @throws \Throwable
*/
public function handle(TwoFactorChallengeInitiated $event): void
{
if ($event->user->usesTwoFactorAuthApp()) {
return;
}
$otp = $this->twoFactorService->generateEmailOTP($event->user);
$event->user->notify(new TwoFactorChallengeNotification($otp));
}
}
This listener generates an OTP using the TwoFactorAuthenticationService
and sends it to the user via the TwoFactorChallengeNotification
. It exits early if the user is using an authenticator app instead of email-based 2FA.
Register this event and its listener in the boot
method of the AppServiceProvider
:
use App\Events\TwoFactorChallengeInitiated;
use App\Listeners\HandleTwoFactorChallenge;
use Illuminate\Support\Facades\Event;
/**
* Bootstrap any application services.
*/
public function boot(): void
{
// ... existing code to create the jwt auth guard
Event::listen(
TwoFactorChallengeInitiated::class,
HandleTwoFactorChallenge::class,
);
}
Next, create a TwoFactorAuthenticationService
and add the generateEmailOTP
method and related functions to it:
<?php
declare(strict_types=1);
namespace App\Services;
use App\Support\Enums\TwoFactorAuthenticationMethod;
use OTPHP\TOTP;
use ParagonIE\ConstantTime\Base32;
class TwoFactorAuthenticationService
{
/**
* Generate a time-based one-time password for the user.
*
* @throws \Throwable
*/
public function generateEmailOTP(User $user): string
{
$secret = $user->two_factor_secret ?? $this->generateSecretKey();
if ($user->two_factor_secret === null) {
$user->update(['two_factor_secret' => $secret]);
}
return $this->createTOTP($secret, $user->email, $user->two_factor_method)
->now();
}
/**
* Generate a TOTP secret of a given length in bytes.
* Length must be a power of 2 and at least 16.
*
* @param int $length
*
* @return string
*
* @throws \Throwable
*/
public function generateSecretKey(int $length = 32): string
{
throw_if(
$length < 16 || ($length & ($length - 1)) !== 0,
new \UnexpectedValueException('The length of the secret must be a power of two and at least 16.')
);
return Base32::encodeUpper(random_bytes($length));
}
/**
* Create a TOTP instance.
*/
public function createTOTP(string $secret, string $label, TwoFactorAuthenticationMethod $method): TOTP
{
$totp = TOTP::create($secret, $method->otpPeriod(), 'sha1', 6);
$totp->setIssuer(config('app.name'));
$totp->setLabel($label);
return $totp;
}
}
The generateEmailOTP
method creates a time-based one-time password by checking for an existing user secret key or generating a new one if needed. It then creates a TOTP instance using the user's email as an identifier and returns a temporary code. The generateSecretKey
method produces a secure base32-encoded secret that serves as the foundation for 2FA, while the createTOTP
method configures the TOTP instance with specific parameters to ensure secure authentication.
Let's add the otpPeriod
method to the TwoFactorAuthenticationMethod
enum to define code validity periods:
public function otpPeriod(): int
{
return match ($this) {
self::EMAIL => 300,
default => 30,
};
}
Finally, create the TwoFactorChallengeNotification
to deliver OTPs to users:
<?php
declare(strict_types=1);
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class TwoFactorChallengeNotification extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct(private string $token) {}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage())
->subject('Your Two-Factor Authentication Code')
->line("Use the One-Time Password (OTP) to complete your login: {$this->token}.")
->line('This code will expire in 5 minutes.')
->line('If you did not request this code, please ignore this email.')
->line('For security reasons, do not forward this email to anyone.');
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
];
}
}
Completing Two-Factor Challenge
We are now sending OTPs to users via email. Therefore, we need an endpoint that allows them to complete the 2FA challenge. Open the api.php
routes file and add this route:
Route::middleware(['auth:api'])
->post('/two-factor/verify', TwoFactorChallengeController::class)
->name('two-factor.verify');
Next, create the controller to handle this endpoint:
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\TwoFactorAuthenticationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class TwoFactorVerificationController extends Controller
{
/**
* Handle the incoming request.
*
* @throws \Throwable
*/
public function __invoke(Request $request, TwoFactorAuthenticationService $twoFactorService): JsonResponse
{
$request->validate(['code' => ['required', 'string']]);
/** @var User $user */
$user = auth()->guard('api')->user();
$response = $twoFactorService->completeTwoFactorChallenge($user, $request->input('code'));
return response()->json(['data' => $response->toArray()], Response::HTTP_OK);
}
}
This controller validates the request for a required code
, retrieves the authenticated user, verifies the code using the TwoFactorAuthenticationService
, and returns a JSON response with an HTTP 200 (OK) status.
Finally, add the completeTwoFactorChallenge
method to the TwoFactorAuthenticationService
:
use App\Builders\JwtPayloadBuilder;
use App\Support\DataTransferObjects\AuthenticationResponse;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Auth\Events\Authenticated;
use Illuminate\Support\Carbon;
use Illuminate\Validation\ValidationException;
use App\Support\Enums\JsonWebTokenScope;
/**
* @throws \Throwable
*/
public function completeTwoFactorChallenge(User $user, string $otp): AuthenticationResponse
{
throw_if(
! $user->hasTwoFactorAuthenticationEnabled(),
new AuthorizationException('Two factor authentication is not enabled for this user.')
);
$isValidOneTimePassword = $this->verifyOneTimePassword($user, $otp);
throw_if(
! $isValidOneTimePassword,
ValidationException::withMessages(['code' => ['The code is invalid.']])
);
$payload = (new JwtPayloadBuilder($user))
->issuedNow()
->addScope(JsonWebTokenScope::ALL_SCOPES)
->getPayload();
$token = (new JWTCodec())->encode($payload);
throw_if(
$token === null,
new AuthenticationException('Failed to generate authentication token.'),
);
$response = new AuthenticationResponse(
$user,
$token,
Carbon::parse($payload['exp']),
false
);
event(new Authenticated('jwt', $user));
return $response;
}
/**
* Verify a time-based one-time password (TOTP).
*/
public function verifyOneTimePassword(User $user, string $otp): bool
{
if ($user->two_factor_secret === null) {
return false;
}
return $this->createTOTP($user->two_factor_secret, $user->email, $user->two_factor_method)
->verify($otp, null, 1);
}
The completeTwoFactorChallenge
method validates the user's OTP and, if successful, issues a full authentication token. It first checks if 2FA is enabled for the user, then verifies the provided code. If valid, it generates a JWT with full access scopes and returns an authentication response containing the user details, token, and expiration time. Upon successful authentication, it also triggers the Authenticated
event. The verifyOneTimePassword
method creates a time-based OTP (TOTP) instance with the user’s secret key and confirms that the provided code is valid within a one-time window.
With all the changes we've made, the login flow now adapts based on the user's two-factor authentication (2FA) settings. If a user has disabled 2FA, they receive a bearer token with full access scopes immediately upon successful login. However, if 2FA is enabled—specifically email-based 2FA—the user will receive a one-time password (OTP) via email. This OTP is valid for 5 minutes and must be used within that window to complete the 2FA challenge. The bearer token issued for the 2FA challenge is temporary and expires after 15 minutes. If the user does not complete the 2FA challenge within this time frame, they will need to restart the login process.
Conclusion
In this tutorial, we've successfully implemented email-based two-factor authentication (2FA) for our Laravel API using Firebase PHP JWT, enhancing our login flow with OTP verification. This involved modifying the database schema, creating service classes for generating and verifying OTPs, and establishing a complete authentication flow with temporary tokens and verification endpoints. We've also improved code maintainability and extensibility through the use of data transfer objects and service classes.
However, to build a truly robust authentication system, we need to expand on this foundation. In our next tutorial, we'll implement app-based 2FA using authenticator apps like Google Authenticator and Authy, enabling users switch between email and app-based 2FA methods according to their preferences, and allowing them to disable 2FA entirely if they want to. Additionally, we'll strengthen security by adding protections against replay attacks and introduce route middleware to validate token scopes, ensuring partially authenticated users can only access appropriate endpoints. Stay tuned for the next installment where we'll implement these crucial security enhancements!
The code for this tutorial is available on GitHub for you to read, clone, fork or star. If you have any challenges with the tutorial, I can be reached via Twitter/X or LinkedIn. Please don't forget to share if you have found it useful.