How to Implement Google Authenticator (TOTP) Two-Factor Authentication in a Stateless Laravel API Using Laravel Passport
16 Jul 2026
Laravel
5 min read
Since Passport issues stateless OAuth2 tokens, issuing a valid token before 2FA verification is a major security risk. Instead, we will use a cache-backed temporary token pattern:
- Step 1: The user logs in with their email and password.
- Step 2: If 2FA is disabled, we immediately return the Passport access token. If 2FA is enabled, we return a short-lived
temp_token(stored in the cache for 5 minutes). - Step 3: The frontend prompts the user for their 6-digit OTP and submits it along with the
temp_tokento get their final Passport token.
Here is the step-by-step implementation guide:
2FA & Passport Setup Sequence
- Install Dependencies: Terminal. Install the standard Google 2FA engine and the QR code renderer package via Composer:
composer require pragmarx/google2fa bacon/bacon-qr-code
- Create Database Migration: Database Schema.
Create a migration to add 2FA fields to your
userstable:
php artisan make:migration add_two_factor_columns_to_users_table
In the generated file, add the necessary columns:
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
// Secret key generated for Google Authenticator
$table->text('google2fa_secret')->nullable();
// Flag to check if they have actively enabled 2FA
$table->boolean('google2fa_enabled')->default(false);
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['google2fa_secret', 'google2fa_enabled']);
});
}
};
Run the migration:
php artisan migrate
- Configure the User Model: App\Models\User.php.
Update your
Usermodel to allow mass-assignment on the new columns.
Crucial Security Tip: Always cast your
google2fa_secretto'encrypted'. This guarantees that even if your database is leaked, attackers cannot extract the raw secrets to bypass 2FA.
protected $fillable = [
'name',
'email',
'password',
'google2fa_secret',
'google2fa_enabled',
];
protected $casts = [
'password' => 'hashed',
'google2fa_secret' => 'encrypted', // Automatic encryption/decryption
'google2fa_enabled' => 'boolean',
];
- Define API Routes: routes/api.php. Register the endpoints. We need public endpoints for logging in and authenticated endpoints for managing (enabling/disabling) 2FA settings:
use App\Http\Controllers\AuthController;
use App\Http\Controllers\TwoFactorController;
use Illuminate\Support\Facades\Route;
// Public Auth Endpoints
Route::post('/login', [AuthController::class, 'login']);
Route::post('/login/verify-2fa', [AuthController::class, 'verify2FA']);
// Protected Auth Endpoints (Requires Passport Guard)
Route::middleware('auth:api')->group(function () {
Route::post('/2fa/setup', [TwoFactorController::class, 'setup']);
Route::post('/2fa/enable', [TwoFactorController::class, 'enable']);
Route::post('/2fa/disable', [TwoFactorController::class, 'disable']);
});
- Build the Optional Login Flow: App\Http\Controllers\AuthController.php. Create the main controller handling standard password verification and conditional 2FA challenge redirection:
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use PragmaRX\Google2FA\Google2FA;
class AuthController extends Controller
{
public function login(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required|string',
]);
$user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
return response()->json(['message' => 'Invalid credentials.'], 401);
}
// Optional 2FA Check
if ($user->google2fa_enabled) {
// Issue an opaque temporary token valid for 5 minutes
$tempToken = Str::random(60);
Cache::put("2fa_login_{$tempToken}", $user->id, now()->addMinutes(5));
return response()->json([
'requires_2fa' => true,
'temp_token' => $tempToken,
'message' => 'Please provide your Google Authenticator verification code.'
]);
}
// Normal Flow: 2FA is disabled, issue Passport token immediately
$tokenResult = $user->createToken('Personal Access Token');
return response()->json([
'requires_2fa' => false,
'access_token' => $tokenResult->accessToken,
'token_type' => 'Bearer',
'user' => $user->only(['id', 'name', 'email'])
]);
}
public function verify2FA(Request $request)
{
$request->validate([
'temp_token' => 'required|string',
'otp' => 'required|digits:6',
]);
// Resolve temporary user from Cache
$userId = Cache::get("2fa_login_{$request->temp_token}");
if (!$userId) {
return response()->json(['message' => 'Verification window expired. Please try logging in again.'], 422);
}
$user = User::findOrFail($userId);
$google2fa = new Google2FA();
// Validate the 6-digit OTP code against user secret
$isValid = $google2fa->verifyKey($user->google2fa_secret, $request->otp);
if (!$isValid) {
return response()->json(['message' => 'Invalid verification code.'], 422);
}
// Cleanup the temporary cache key
Cache::forget("2fa_login_{$request->temp_token}");
// Create Passport Token
$tokenResult = $user->createToken('Personal Access Token');
return response()->json([
'access_token' => $tokenResult->accessToken,
'token_type' => 'Bearer',
'user' => $user->only(['id', 'name', 'email'])
]);
}
}
- Write the 2FA Setup/Enable Logic: App\Http\Controllers\TwoFactorController.php. Create the management endpoints for users to initialize, verify, and disable 2FA within their profile:
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use PragmaRX\Google2FA\Google2FA;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
class TwoFactorController extends Controller
{
protected $google2fa;
public function __construct()
{
$this->google2fa = new Google2FA();
}
// 1. Initialize 2FA (Generate Secret and QR Code)
public function setup(Request $request)
{
$user = $request->user();
// Generate secret key
$secret = $this->google2fa->generateSecretKey();
// Temporarily store it, but do NOT set enabled = true yet
$user->google2fa_secret = $secret;
$user->save();
// Generate the QR URL
$qrCodeUrl = $this->google2fa->getQRCodeUrl(
config('app.name'),
$user->email,
$secret
);
// Render SVG output of the QR Code for frontend displaying
$renderer = new ImageRenderer(
new RendererStyle(250),
new SvgImageBackEnd()
);
$writer = new Writer($renderer);
$qrCodeSvg = $writer->writeString($qrCodeUrl);
return response()->json([
'secret' => $secret,
'qr_code_svg' => $qrCodeSvg // Send SVG markup directly to your UI
]);
}
// 2. Validate and Turn ON 2FA (Ensures they actually scanned it)
public function enable(Request $request)
{
$request->validate([
'otp' => 'required|digits:6',
]);
$user = $request->user();
// Verify they put the correct code from their freshly scanned app
$isValid = $this->google2fa->verifyKey($user->google2fa_secret, $request->otp);
if (!$isValid) {
return response()->json(['message' => 'Invalid verification code.'], 422);
}
$user->google2fa_enabled = true;
$user->save();
return response()->json(['message' => 'Two-Factor Authentication activated successfully.']);
}
// 3. Disable 2FA
public function disable(Request $request)
{
$request->validate([
'password' => 'required|string',
]);
$user = $request->user();
// Require password verification to ensure owner action
if (!Hash::check($request->password, $user->password)) {
return response()->json(['message' => 'Incorrect password confirmation.'], 422);
}
$user->google2fa_secret = null;
$user->google2fa_enabled = false;
$user->save();
return response()->json(['message' => 'Two-Factor Authentication deactivated.']);
}
}
Important Developer Tips
- Handling Front-End SVG:
The
qr_code_svgreturned from/2fa/setupis a pure string of SVG raw data. On your frontend framework (React, Vue, etc.), you can easily inject this safely inside your template:
<!-- Vue -->
<div v-html="qr_code_svg"></div>
<!-- React -->
<div dangerouslySetInnerHTML={{ __html: qr_code_svg }} />
- Clock Sync on Server: TOTP relies heavily on the time on your server syncing perfectly with the user’s mobile phone. If you run into issues where correct codes are rejected, ensure your server is running a time-sync service like NTP (Network Time Protocol) to avoid drift.
Laravel
Laravel Passport
Google Authenticator
TOTP
Two-Factor Authentication
2FA
OAuth2
Stateless API
API Authentication
PHP
REST API
Web Security