In this laravel tutorial titled “laravel 12 reset password using OTP sanctum api example”, we will learn how to implement Password Reset using OTP (One-Time Password) in Laravel 12 REST API with Sanctum authentication. This guide covers generating OTP, sending OTP via email, verifying OTP, and resetting the password securely.
Password reset with OTP is a common and secure method used in mobile apps and single-page applications where users reset their password without visiting a link.
Table of Contents
Why Use OTP for Password Reset?
- No need for email verification links
- Perfect for mobile and Android/Flutter/iOS apps
- Works smoothly with SPA frameworks like Vue, React, and Angular
- Easy to integrate in Postman or front-end clients
- More secure as OTP expires quickly
Step-by-Step Implementation Guide
Step 1: Install Laravel 12
Run the following command to create a fresh laravel project:
composer create-project laravel/laravel laravel-otp-reset Configure your database connection in the .env file:
DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=laravel_sanctum_auth DB_USERNAME=root DB_PASSWORD=your_password For password reset functionality, configure email settings in your .env file:
MAIL_MAILER=smtp MAIL_SCHEME=null MAIL_HOST=smtp.gmail.com MAIL_PORT=587 MAIL_USERNAME=itstuffsolutions@gmail.com MAIL_PASSWORD=chxuxkirrhkcsbus MAIL_FROM_ADDRESS="itstuffsolutions@gmail.com" MAIL_FROM_NAME=itstuffsolutions Read Also: How to Send Mail in Laravel 12 Using Gmail SMTP Step-by-Step Tutorial
Step 2: Install and Configure Laravel Sanctum
Run the following command to install Sanctum:
php artisan install:api This single command takes care of several setup tasks for you.
- Adds the Laravel Sanctum package to your project
- Generates the default api.php routing file
- Publishes Sanctum’s configuration settings
- Provides the migration file required for the personal_access_tokens table
After that, running the migration command will build all the essential database tables needed for Sanctum to function properly.
php artisan migrate Step 3: Update the User Model
Before you can issue API tokens in Laravel Sanctum, your User model must be prepared to handle them. Sanctum provides a special trait HasApiTokens that equips the model with all the methods needed for token generation and management.
Add the trait to your User model as shown below:
<?php namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ use HasFactory, Notifiable,HasApiTokens; /** * The attributes that are mass assignable. * * @var list<string> */ protected $fillable = [ 'name', 'email', 'password', ]; /** * The attributes that should be hidden for serialization. * * @var list<string> */ protected $hidden = [ 'password', 'remember_token', ]; /** * Get the attributes that should be cast. * * @return array<string, string> */ protected function casts(): array { return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', ]; } } Why Is This Trait Important?
HasApiTokens integrates powerful token-handling capabilities into your model, enabling features such as:
- Creating personal access tokens
- Accessing and managing a user’s existing tokens
- Retrieving the currently authenticated token
- Working with permissions for each token
This trait is essential because Sanctum relies on it to authenticate API users and securely manage their tokens behind the scenes.
Step 4: Create password_reset_otps Table
Instead of using the default password_reset_tokens table (which is designed for long string tokens), create a dedicated table for numeric OTPs to handle expiration and validation efficiently.
php artisan make:model PasswordResetOtp -m This command creates the PasswordResetOtp model and its migration. Now, open the database/migrations/xxxx_password_reset_otps.php file and update it with the following code:
<?php 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::create('password_reset_otps', function (Blueprint $table) { $table->id(); $table->string('email'); $table->string('otp', 10); $table->timestamp('expires_at')->nullable(); $table->timestamps(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('password_reset_otps'); } }; Run the migration:
php artisan migrate Now Open app/Models/PasswordResetOtp.php file and update it with the following code:
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; class PasswordResetOtp extends Model { protected $fillable = [ 'email', 'otp', 'expires_at' ]; protected $casts = [ 'expires_at' => 'datetime' ]; } Step 5: Create the Email
Create a mailable class to send the OTP to the user.
php artisan make:mail OtpPasswordResetMail Update the Mail Class:
<?php namespace App\Mail; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; class OtpPasswordResetMail extends Mailable { use Queueable, SerializesModels; /** * Create a new message instance. */ public function __construct( public string $userName, public string $otp, public int $expiryMinutes = 10) {} /** * Get the message envelope. */ public function envelope(): Envelope { return new Envelope( subject: 'Password Reset OTP - Valid for ' . $this->expiryMinutes . ' minutes', ); } /** * Get the message content definition. */ public function content(): Content { return new Content( view: 'emails.otp-reset', ); } /** * Get the attachments for the message. * * @return array<int, \Illuminate\Mail\Mailables\Attachment> */ public function attachments(): array { return []; } } Create the View (resources/views/emails/otp-reset.blade.php):
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Password Reset OTP</title> </head> <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"> <div style="max-width: 600px; margin: 0 auto; border: 1px solid #ddd; border-radius: 8px; overflow: hidden;"> <!-- Header --> <div style="background-color: #007bff; color: white; padding: 20px; text-align: center;"> <h2>Password Reset Request</h2> </div> <!-- Content --> <div style="padding: 30px;"> <p>Hi {{ $userName }},</p> <p>We received a request to reset your password. Use the OTP below to proceed:</p> <!-- OTP Box --> <div style="background-color: #f8f9fa; border: 2px solid #007bff; border-radius: 8px; padding: 20px; text-align: center; margin: 20px 0;"> <p style="margin: 0; font-size: 12px; color: #666;">Your One-Time Password</p> <h1 style="margin: 10px 0; font-size: 36px; letter-spacing: 5px; color: #007bff; font-family: monospace;"> {{ $otp }} </h1> <p style="margin: 10px 0; font-size: 12px; color: #e74c3c;"> ⏱️ Expires in {{ $expiryMinutes }} minutes </p> </div> <p style="margin: 20px 0;"> <strong>Important Security Notice:</strong> </p> <ul style="margin: 10px 0; padding-left: 20px;"> <li>Never share this OTP with anyone</li> <li>Our team will never ask for your OTP</li> <li>If you didn't request this, ignore this email</li> </ul> <p style="margin-top: 20px;"> If you have trouble using this OTP, you can request a new one by trying to reset your password again. </p> <p style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd; font-size: 12px; color: #666;"> Best regards,<br> <strong>{{ config('app.name') }} Team</strong> </p> </div> <!-- Footer --> <div style="background-color: #f8f9fa; padding: 15px; text-align: center; font-size: 12px; color: #666;"> <p style="margin: 0;">This is an automated email. Please do not reply to this message.</p> </div> </div> </body> </html> Step 6: Create Forgot Password Controller
Run the following command to create forgot password controller:
php artisan make:controller API/ForgotPasswordController Implementation:
<?php namespace App\Http\Controllers\API; use Illuminate\Support\Facades\Mail; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; use App\Mail\OtpPasswordResetMail; use App\Models\PasswordResetOtp; use Illuminate\Http\Request; use Illuminate\Support\Str; use App\Models\User; use Carbon\Carbon; use Validator; class ForgotPasswordController extends Controller { /** * Handle forgot password request * * Generates OTP and sends via email */ public function forgotPassword(Request $request): JsonResponse { try { $validator = Validator::make($request->all(), [ 'email' => 'required|email', ]); if($validator->fails()){ return response()->json([ 'status' => false, 'message' => 'Validation Error', 'errors' => $validator->errors() ], 422); } $email = $request->email; $user = User::where('email', $email)->first(); if (!$user) { return response()->json([ 'status' => false,'error' => 'Email not found'], 404); } // Generate a 6-digit numeric OTP $otp = rand(100000, 999999); // Create new OTP with expiry PasswordResetOtp::create([ 'email'=> $email, 'otp' => $otp, 'expires_at' => Carbon::now()->addMinutes(10) ]); //Send email with OTP Mail::to($email)->send( new OtpPasswordResetMail($user->name, $otp, 10) ); return response()->json([ 'success' => true, 'message' => 'OTP sent to your email address. Check your inbox.', 'data' => [ 'email' => $email, 'expires_in_minutes' => 10 ] ], 200); } catch (\Exception $e) { return response()->json([ 'success' => false, 'message' => 'Failed to send OTP. Please try again.', 'error' => config('app.debug') ? $e->getMessage() : null ], 500); } } /** * Verify OTP */ public function verifyOtp(Request $request): JsonResponse { try { $validator = Validator::make($request->all(), [ 'email' => 'required|email|exists:users,email', 'otp' => 'required|digits:6', ]); if($validator->fails()){ return response()->json([ 'status' => false, 'message' => 'Validation Error', 'errors' => $validator->errors() ], 422); } $otpRecord = PasswordResetOtp::where(['email'=> $request->email,'otp'=> $request->otp])->first(); if (!$otpRecord) { return response()->json([ 'status' => false,'error' => 'Invalid OTP'], 400); } // Check expiration if (Carbon::parse($otpRecord->expires_at)->isPast()) { return response()->json([ 'status' => false,'message' => 'OTP has expired.'], 400); } return response()->json([ 'status' => true,'message' => 'OTP verified successfully'], 200); } catch (\Exception $e) { return response()->json([ 'success' => false, 'message' => 'Invalid OTP or email.', 'error' => config('app.debug') ? $e->getMessage() : null ], 500); } } public function resetPassword(Request $request): JsonResponse { try { $validator = Validator::make($request->all(), [ 'email' => 'required|email|exists:users,email', 'otp' => 'required|digits:6', 'password' => 'required|string|min:8|confirmed', ]); if($validator->fails()){ return response()->json([ 'status' => false, 'message' => 'Validation Error', 'errors' => $validator->errors() ], 422); } $otpRecord = PasswordResetOtp::where(['email'=> $request->email,'otp'=> $request->otp])->first(); if (!$otpRecord) { return response()->json([ 'status' => false, 'message' => 'Invalid OTP' ], 400); } // Update User Password User::where('email', $request->email)->update([ 'password' => bcrypt($request->password) ]); // Delete OTP record PasswordResetOtp::where('email', $request->email) ->delete(); return response()->json([ 'status' => true, 'message' => 'Password has been reset successfully' ]); } catch (\Exception $e) { return response()->json([ 'success' => false, 'message' => 'Invalid OTP or email.', 'error' => config('app.debug') ? $e->getMessage() : null ], 500); } } } Read Also: Laravel 12 REST API Authentication Using Sanctum with Password Reset
Step 7: Register API Routes
Open routes/api.php and add the endpoints. Since users requesting a password reset are not logged in, these routes must be public (outside the auth:sanctum middleware).
<?php use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; use App\Http\Controllers\API\ForgotPasswordController; Route::post('forgot-password', [ForgotPasswordController::class, 'forgotPassword']); Route::post('verify-otp', [ForgotPasswordController::class, 'verifyOtp']); Route::post('reset-password', [ForgotPasswordController::class, 'resetPassword']); Route::get('/user', function (Request $request) { return $request->user(); })->middleware('auth:sanctum'); Step 8: Test with Postman
You can test this using Postman:
Request OTP
Endpoint: POST http://localhost:8000/api/forgot-password

As shown in the Forgot Password API response, the OTP has been generated successfully. The user will receive an OTP similar to the one displayed below. Use this OTP to complete the password reset process.

Verify OTP
Endpoint: POST http://localhost:8000/api/verify-otp
Body:
{ "email": "user@example.com", "otp": "123456", } Result:
{ 'status': true, 'message': 'OTP verified successfully' } Reset Password
Endpoint: POST http://localhost:8000/api/reset-password
Body:
{ "email": "user@example.com", "otp": "123456", "password": "NewPassword123!", "password_confirmation": "NewPassword123!" } Result:
{ 'status': true, 'message' => 'Password has been reset successfully' } GitHub Repository: https://github.com/itstuffsolutions/laravel-12-reset-password-using-otp-sanctum-api-example
Conclusion
In this tutorial, we implemented OTP-based Password Reset API using Laravel 12 + Sanctum. This method is secure, flexible, and ideal for mobile apps and SPA applications.
You learned:
- How to generate and send OTP
- How to verify OTP
- How to reset password
- How to create routes and test using Postman
You can now integrate this flow into your application for a seamless user experience.
