DEV Community

ashrakt
ashrakt

Posted on

Complete API Authentication with Laravel 12 Sanctum

Laravel Sanctum provides an authentication system for SPAs (single page applications), mobile applications, and simple, token based APIs. Sanctum allows each user of your application to generate multiple API tokens for their account. These tokens may be granted abilities / scopes which specify which actions the tokens are allowed to perform.


folders

App->[
     Http->[
           Controllers\Api\Auth->[
                                AuthController,
                                OTPController,
                                PasswordResetController
         ],Requests\Auth->[
                         LoginUserRequest,
                         RegisterUserRequest,
                         ResetPasswordRequest,
                         SendOTPRequest,
                         VerifyOTPRequest
        ],Resources->[
                      SuccessResource,
                      ErrorResource,
                      User->[
                            AuthResource,
                            UserResource]
    ],Jobs->[SendMailJob],
      Mail->[OTPMail]
Enter fullscreen mode Exit fullscreen mode

1: Install Laravel 12 and Sanctum

# Create new Laravel 12 project
laravel new sanctum-auth
cd sanctum-auth

# Install Sanctum
php artisan install:api

# Run migrations (creates personal_access_tokens table)
php artisan migrate

Enter fullscreen mode Exit fullscreen mode

2: Configure Sanctum

config/sanctum.php

  'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
        '%s%s',
        'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
        Sanctum::currentApplicationUrlWithPort(),
    ))),
Enter fullscreen mode Exit fullscreen mode

Stateful domains tell Sanctum which domains can use cookies and sessions for authentication, allowing the frontend to communicate with the API seamlessly!

.env
SANCTUM_STATEFUL_DOMAINS=localhost,127.0.0.1,127.0.0.1:3000


3: Update User Model

  • app/Models/User.php
<?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 HasApiTokens, HasFactory, Notifiable;

    protected $fillable = [
        'name',
        'email',
        'password',
        'phone',
        'email_verified_at',
        'otp',
        'otp_expires_at',
        'otp_verified_at'
    ];

    protected $hidden = [
        'password',
        'remember_token',
        'otp'

    ];


    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
            'otp_expires_at' => 'datetime',
            'otp_verified_at' => 'datetime',
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

4: Create Auth Controller, Register And Login Request, Auth And User Resource

Auth Controller
php artisan make:controller Api/Auth/AuthController

  • app/Http/Controllers/AuthController.php
<?php

namespace App\Http\Controllers\Api\Auth;

use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginUserRequest;
use App\Http\Requests\Auth\RegisterUserRequest;
use App\Http\Resources\User\AuthResource;
use App\Http\Resources\ErrorResource;
use App\Http\Resources\SuccessResource;
use App\Http\Resources\User\UserResource;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;

class AuthController extends Controller
{
    public function register(RegisterUserRequest $request)
    {
        $validated = $request->validated();

        $user = User::create([
            'name' => $validated['name'],
            'email' => $validated['email'],
            'password' => Hash::make($validated['password']),
            'phone' => $validated['phone'],
        ]);

        $token = $user->createToken('auth_token')->plainTextToken;

        $authData = [
            'user' => $user,
            'token' => $token,
            'token_type' => 'Bearer'
        ];

        return new AuthResource($authData);
    }

    public function login(LoginUserRequest $request)
    {
        $validated = $request->validated();

        $user = User::where('email', $validated['email'])->first();

        if (!$user || !Hash::check($validated['password'], $user->password)) {
            return new ErrorResource([
                'message' => 'Invalid credentials',
                'status_code' => 401
            ]);
        }

        $token = $user->createToken('auth_token')->plainTextToken;

        $authData = [
            'user' => $user,
            'token' => $token,
            'token_type' => 'Bearer'
        ];

        return new AuthResource($authData);
    }

    public function logout(Request $request)
    {
        $request->user()->currentAccessToken()->delete();

        return new SuccessResource([
            'message' => 'Logged out successfully'
        ]);
    }


//The user() function retrieves the authenticated user's profile data using their Bearer token.

    public function user(Request $request)
    {
        return new SuccessResource([
            'message' => 'User data retrieved successfully',
            'data' => new UserResource($request->user())
        ]);
    }
}

Enter fullscreen mode Exit fullscreen mode
$token = $user->createToken('auth_token');
        return $token;

Enter fullscreen mode Exit fullscreen mode
  • the json respone


Create RegisterUserRequest class

php artisan make:request Auth/RegisterUserRequest

-app/Http/Requests/Auth/RegisterUserRequest.php

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;

class RegisterUserRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true; // Set to true to allow all users
    }

    /**
     * Get the validation rules that apply to the request.
     */
    public function rules(): array
    {
        return [
            'name' => 'required|string|max:255',
            'email' => 'required|string|email|max:255|unique:users',
            'password' => 'required|string|min:8|confirmed',
            'phone' => 'required|string|max:20'
        ];
    }

    /**
     * Get custom messages for validator errors.
     *
     * @return array<string, string>
     */
    public function messages(): array
    {
        return [
            'name.required' => 'The name field is required.',
            'name.string' => 'The name must be a string.',
            'name.max' => 'The name may not be greater than 255 characters.',

            'email.required' => 'The email field is required.',
            'email.email' => 'Please enter a valid email address.',
            'email.unique' => 'This email is already registered.',

            'password.required' => 'The password field is required.',
            'password.min' => 'The password must be at least 8 characters.',
            'password.confirmed' => 'Password confirmation does not match.',

            'phone.required' => 'The phone field is required.',
            'phone.string' => 'The phone must be a string.',
            'phone.max' => 'The phone may not be greater than 20 characters.',
        ];
    }



    protected function failedValidation(Validator $validator)
    {
        throw new HttpResponseException(
            response()->json([
                'status' => 'error',
                'message' => 'Validation failed',
                'errors' => $validator->errors()
            ], 422)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Login Request

-php artisan make:request Auth/LoginUserRequest

<?php

namespace App\Http\Requests\Auth;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;


class LoginUserRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'email' => 'required|email',
            'password' => 'required'
        ];
    }

    public function messages(): array
    {
        return [
            'email.required' => 'The email field is required.',
            'email.email' => 'Please enter a valid email address.',
            'password.required' => 'The password field is required.',
        ];
    }


    protected function failedValidation(Validator $validator)
    {
        throw new HttpResponseException(
            response()->json([
                'status' => 'error',
                'message' => 'Validation failed',
                'errors' => $validator->errors()
            ], 422)
        );
    }
}

Enter fullscreen mode Exit fullscreen mode

Auth Resource

php artisan make:resource User/AuthResource
app/Http/Resources/User/AuthResource.php

<?php

namespace App\Http\Resources\User;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class AuthResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'user' => new UserResource($this['user']),
            'access_token' => $this['token'],
            'token_type' => $this['token_type'] ?? 'Bearer',
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

User Resource

php artisan make:resource USer/UserResource
app/Http/Resources/User/UserResource.php

<?php

namespace App\Http\Resources\User;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class UserResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->email,
            'phone' => $this->phone,
            'email_verified_at' => $this->email_verified_at?->toDateTimeString(),
            'created_at' => $this->created_at?->toDateTimeString(),
            'updated_at' => $this->updated_at?->toDateTimeString(),
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Success Resource

php artisan make:resource SuccessResource

  • app/Http/Resources/SuccessResource.php
<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class SuccessResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'status' => 'success',
            'message' => $this['message'],
            'data' => $this['data'] ?? null,
        ];
    }

    public function withResponse($request, $response)
    {
        $response->setStatusCode($this['status_code'] ?? 200);
    }
}
Enter fullscreen mode Exit fullscreen mode

Error Resource

php artisan make:resource ErrorResource
-app/Http/Resources/ErrorResource.php

<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class ErrorResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'status' => 'error',
            'message' => $this['message'],
            'errors' => $this['errors'] ?? null,
        ];
    }

    public function withResponse($request, $response)
    {
        $response->setStatusCode($this['status_code'] ?? 400);
    }
}
Enter fullscreen mode Exit fullscreen mode

*Create OTP Controller, OTP Requests *

OTP Controller

This OTPController class is a One-Time Password (OTP) authentication controller for a Laravel application. Here's what it does:

Main Purpose

1- Email verification during registration
2- Two-factor authentication (2FA)
3- Password reset verification
4- Account recovery

-php artisan make:controller Api/Auth/OTPController

<?php

namespace App\Http\Controllers\Api\Auth;

use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\SendOTPRequest;
use App\Http\Requests\Auth\VerifyOTPRequest;
use App\Http\Resources\ErrorResource;
use App\Http\Resources\SuccessResource;
use App\Jobs\SendMailJob;
use App\Mail\OTPMail;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail;

class OTPController extends Controller
{
    public function sendOTP(SendOTPRequest $request)
    {
        $validated = $request->validated();

        $user = User::where('email', $validated['email'])->first();

        $otp = rand(100000, 999999);
        $otpExpires = Carbon::now()->addMinutes(10);


        try {
            // Send OTP via Email
            SendMailJob::dispatch(
                $user->name,
                $user->email,
                $otp
            );


            $user->update([
                'otp' => $otp,
                'otp_expires_at' => $otpExpires
            ]);

            return new SuccessResource([
                'message' => 'OTP sent successfully to your email',
                'data' => [
                    'expires_in' => 10,
                    'email' => $user->email
                ]
            ]);
        } catch (\Exception $e) {
            return new ErrorResource([
                'message' => 'Failed to send OTP. Please try again.',
                'error_code' => 'EMAIL_SEND_FAILED',
                'status_code' => 500
            ]);
        }
    }


    public function verifyOTP(VerifyOTPRequest $request)
    {
        $validated = $request->validated();

        $user = User::where('email', $validated['email'])
            ->where('otp', $validated['otp'])
            ->where('otp_expires_at', '>', Carbon::now())
            ->first();

        if (!$user) {
            return new ErrorResource([
                'message' => 'Invalid or expired OTP',
                'status_code' => 400
            ]);
        }

        $user->update([
            'otp' => null,
            'otp_expires_at' => null,
            'email_verified_at' => Carbon::now()
        ]);

        return new SuccessResource([
            'message' => 'OTP verified successfully'
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

OTP Requests

php artisan make:request Auth/SendOTPRequest
php artisan make:request Auth/VerifyOTPRequest

-app/Http/Requests/Auth/SendOTPRequest.php

<?php

namespace App\Http\Requests\Auth;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;


class SendOTPRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'email' => 'required|email|exists:users,email'
        ];
    }


      public function messages(): array
    {
        return [
            'email.required' => 'The email field is required.',
            'email.email' => 'Please enter a valid email address.',
            'email.exists' => 'No account found with this email address.',
        ];
    }


    protected function failedValidation(Validator $validator)
    {
        throw new HttpResponseException(
            response()->json([
                'status' => 'error',
                'message' => 'Validation failed',
                'errors' => $validator->errors()
            ], 422)
        );
    }
}

Enter fullscreen mode Exit fullscreen mode

-app/Http/Requests/Auth/VerifyOTPRequest.php

<?php

namespace App\Http\Requests\Auth;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;

class VerifyOTPRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'email' => 'required|email|exists:users,email',
            'otp' => 'required|digits:6'
        ];
    }


    public function messages(): array
    {
        return [
            // Email field messages
            'email.required' => 'Email address is required.',
            'email.email' => 'Please provide a valid email address.',
            'email.exists' => 'This email address is not registered in our system.',

            // OTP field messages
            'otp.required' => 'OTP code is required.',
            'otp.digits' => 'OTP code must be exactly 6 digits.',
        ];
    }


    protected function failedValidation(Validator $validator)
    {
        throw new HttpResponseException(
            response()->json([
                'status' => 'error',
                'message' => 'Validation failed',
                'errors' => $validator->errors()
            ], 422)
        );
    }
}

Enter fullscreen mode Exit fullscreen mode

Queue for Sending Email With Database Driver

Benefits:

-Improved Application Performance -> User doesn't wait for email to send

  • Automatic Retries -> If sending fails, it retries automatically
  • Error Handling -> Failed jobs are logged and can be retried
  • Data Safety -> Jobs are stored even if server restarts

Complete Setup

i: Environment Setup
.env
QUEUE_CONNECTION=database

ii: create queue tables if they don't exist:

php artisan queue:table
php artisan queue:failed-table
php artisan migrate

php artisan make:job SendMailJob

<?php

namespace App\Jobs;

use App\Mail\OTPMail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;

class SendMailJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    protected $name;
    protected $email;
    protected $otp;


    public function __construct($name, $email, $otp)
    {
        $this->name = $name;
        $this->email = $email;
        $this->otp = $otp;
    }



    public function handle(): void
    {
        Mail::to($this->email)->send(new OTPMail(
            otp: $this->otp,
            userName: $this->name,
            expiresIn: 10,
            purpose: 'verification'
        ));
    }

    public function failed(\Throwable $exception): void
    {
        Log::error('SendMailJob failed: ' . $exception->getMessage());
        Log::error('Exception details: ', [
            'file' => $exception->getFile(),
            'line' => $exception->getLine(),
            'trace' => $exception->getTraceAsString()
        ]);
    }
}

Enter fullscreen mode Exit fullscreen mode

Running the Queue Worker

php artisan queue:work


Complete SendGrid Setup Guide

1: Create SendGrid Account

  • Go to SendGrid.com
  • Sign up for a free account (100 emails/day)
  • Verify your email address
  • Complete account setup

2: Get SendGrid API Key

  • Login to SendGrid dashboard
  • Go to Settings → API Keys
  • Click Create API Key
  • Give it a name (e.g., "Laravel App")
  • Choose Full Access or Restricted Access
  • Copy the API key (you won't see it again!)
  • .env file
MAIL_MAILER=smtp
MAIL_HOST=smtp.sendgrid.net
MAIL_PORT=587
MAIL_USERNAME=apikey
MAIL_PASSWORD=your_sendgrid_api_key_here
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=from@example.com
MAIL_FROM_NAME="Your App"
Enter fullscreen mode Exit fullscreen mode

4: Sender Authentication

Option A: Single Sender Verification (Quick Start)

1- Go to Settings → Sender Authentication
2- Click Verify a Single Sender
3-Fill in the form:
From Email: your-email@example.com
From Name: Your Name
Reply To: your-email@example.com
Address: Your physical address (required by law)
Click Create
4-Check your email and click verification link

Option B: Domain Authentication (Recommended for Production)

1- Go to Settings → Sender Authentication
2- Click Authenticate Your Domain
3- Enter your domain (e.g., example.com)
4- Choose Default Link Branding
5- SendGrid provides DNS records - add these to your domain's DNS
6- Wait for verification (can take 24-48 hours)

5: Create Mail Class in Laravel

php artisan make:mail OTPMail

<?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 OTPMail extends Mailable
{
    use Queueable, SerializesModels;

    public $otp;
    public $userName;
    public $expiresIn;
    public $purpose;

    public function __construct($otp, $userName = null, $expiresIn = 10, $purpose = 'verification')
    {
        $this->otp = $otp;
        $this->userName = $userName;
        $this->expiresIn = $expiresIn;
        $this->purpose = $purpose;
    }



    public function content(): Content
    {
        return new Content(
            view: 'emails.otp',
        );
    }

    public function attachments(): array
    {
        return [];
    }
}

Enter fullscreen mode Exit fullscreen mode

Create Email Template

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>OTP Verification - {{ config('app.name') }}</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            line-height: 1.6;
            color: #333;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            padding: 20px;
        }

        .container {
            max-width: 500px;
            margin: 0 auto;
        }

        .email-wrapper {
            background: white;
            border-radius: 15px;
            overflow: hidden;
            box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
        }

        .content {
            padding: 40px 30px;
        }

        .greeting {
            font-size: 18px;
            margin-bottom: 25px;
            color: #4B5563;
        }

        .otp-container {
            text-align: center;
            margin: 30px 0;
        }

        .otp-label {
            font-size: 16px;
            color: #6B7280;
            margin-bottom: 15px;
        }

        .otp-code {
            font-size: 48px;
            font-weight: bold;
            color: #4F46E5;
            letter-spacing: 8px;
            font-family: 'Courier New', monospace;
            background: #F8FAFC;
            padding: 20px;
            border-radius: 10px;
            border: 2px dashed #E5E7EB;
            margin: 15px 0;
        }

        .info-box {
            background: #F0F9FF;
            border: 1px solid #BAE6FD;
            border-radius: 10px;
            padding: 20px;
            margin: 25px 0;
        }

        .info-item {
            display: flex;
            align-items: center;
            margin-bottom: 10px;
        }

        .info-item:last-child {
            margin-bottom: 0;
        }

        .info-icon {
            font-size: 18px;
            margin-right: 12px;
        }

        .footer {
            text-align: center;
            padding: 30px;
            background: #F8FAFC;
            border-top: 1px solid #E5E7EB;
        }

        .footer p {
            color: #6B7280;
            font-size: 14px;
            margin-bottom: 5px;
        }

        .app-name {
            color: #4F46E5;
            font-weight: 600;
        }

        @media (max-width: 600px) {
            .content {
                padding: 30px 20px;
            }

            .otp-code {
                font-size: 36px;
                letter-spacing: 6px;
                padding: 15px;
            }

        }
    </style>
</head>

<body>
    <div class="container">
        <div class="email-wrapper">
            <div class="content">
                <div class="greeting">
                    @if ($userName)
                        Hello <strong>{{ $userName }}</strong>,
                    @else
                        Hello,
                    @endif
                </div>

                <p>You're just one step away! Use the following verification code to complete your request:</p>

                <div class="otp-container">
                    <div class="otp-label">Your verification code:</div>
                    <div class="otp-code">{{ $otp }}</div>
                </div>

                <div class="info-box">
                    <div class="info-item">
                        <span class="info-icon">⏰</span>
                        <span><strong>Expires in:</strong> {{ $expiresIn }} minutes</span>
                    </div>
                    <div class="info-item">
                        <span class="info-icon">🚀</span>
                        <span><strong>Purpose:</strong>
                            @if ($purpose === 'verification')
                                Account Verification
                            @elseif($purpose === 'password_reset')
                                Password Reset
                            @elseif($purpose === 'login')
                                Login Verification
                            @else
                                Security Verification
                            @endif
                        </span>
                    </div>
                </div>
            </div>

            <div class="footer">
                <p>&copy; {{ date('Y') }} <span class="app-name">{{ config('app.name') }}</span>. All rights
                    reserved.</p>
                <p>This email was sent via Secure OTP System</p>
            </div>
        </div>
    </div>
</body>

</html>

Enter fullscreen mode Exit fullscreen mode

Password Reset Controller, Send OTP And Reset password Request

php artisan make:controller Api/Auth/PasswordResetController

  • app/Http/Controllers/Api/Auth/PasswordResetController.php
<?php

namespace App\Http\Controllers\Api\Auth;

use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\ResetPasswordRequest;
use App\Http\Requests\Auth\SendOTPRequest;
use App\Http\Resources\ErrorResource;
use App\Http\Resources\SuccessResource;
use App\Jobs\SendMailJob;
use App\Mail\OTPMail;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;

class PasswordResetController extends Controller
{
    public function forgotPassword(SendOTPRequest $request)
    {
        $validated = $request->validated();

        $user = User::where('email', $validated['email'])->first();

        $otp = rand(100000, 999999);
        $otpExpires = Carbon::now()->addMinutes(15);

        try {
            // Send OTP via Email
            SendMailJob::dispatch(
                $user->name,
                $user->email,
                $otp
            );

            $user->update([
                'otp' => $otp,
                'otp_expires_at' => $otpExpires
            ]);

            return new SuccessResource([
                'message' => 'OTP sent successfully to your email',
                'data' => [
                    'expires_in' => 10,
                    'email' => $user->email
                ]
            ]);
        } catch (\Exception $e) {
            return new ErrorResource([
                'message' => 'Failed to send OTP. Please try again.',
                'error_code' => 'EMAIL_SEND_FAILED',
                'status_code' => 500
            ]);
        }
    }

    public function resetPassword(ResetPasswordRequest $request)
    {
        $validated = $request->validated();

        $user = User::where('email',  $validated['email'])
            ->where('otp', $validated['otp'])
            ->where('otp_expires_at', '>', Carbon::now())
            ->first();

        if (!$user) {
            return new ErrorResource([
                'message' => 'Invalid or expired OTP',
                'status_code' => 400
            ]);
        }

        $user->update([
            'password' => Hash::make($validated['password']),
            'otp' => null,
            'otp_expires_at' => null
        ]);

        $user->tokens()->delete();

        return new SuccessResource([
            'message' => 'Password reset successfully'
        ]);
    }
}

Enter fullscreen mode Exit fullscreen mode

Why $user->tokens()->delete();?

Security measure during password reset to:

  • Invalidate all active sessions
  • Force re-login with new password
  • Prevent token reuse if account was compromised
  • Log out all devices

Result:

  • User must login again on all devices
  • Old tokens become useless
  • Fresh start with new password

Reset Password Request

<?php
namespace App\Http\Requests\Auth;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;


class ResetPasswordRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }


    public function rules(): array
    {
        return [
            'email'    => 'required|email|exists:users,email',
            'otp'      => 'required|digits:6',
            'password' => 'required|string|min:8|confirmed',
        ];
    }


    public function messages(): array
    {
        return [
            'email.required' => 'The email field is required.',
            'email.email' => 'Please enter a valid email address.',
            'email.exists' => 'No account found with this email address.',

            'password.required' => 'The password field is required.',
            'password.min' => 'The password must be at least 8 characters.',
            'password.confirmed' => 'Password confirmation does not match.',

            'otp.required' => 'OTP code is required.',
            'otp.digits' => 'OTP code must be exactly 6 digits.',
        ];
    }


    protected function failedValidation(Validator $validator)
    {
        throw new HttpResponseException(
            response()->json([
                'status' => 'error',
                'message' => 'Validation failed',
                'errors' => $validator->errors()
            ], 422)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Create API Routes

  • routes/api.php
// Include Authentication Routes
require __DIR__ . '/auth.php';
Enter fullscreen mode Exit fullscreen mode
  • routes/auth.php
<?php

use App\Http\Controllers\Api\Auth\AuthController;
use App\Http\Controllers\Api\Auth\OTPController;
use App\Http\Controllers\Api\Auth\PasswordResetController;
use Illuminate\Support\Facades\Route;



// Public routes
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);

// OTP routes
Route::post('/send-otp', [OTPController::class, 'sendOTP']);
Route::post('/verify-otp', [OTPController::class, 'verifyOTP']);

// Password reset routes
Route::post('/forgot-password', [PasswordResetController::class, 'forgotPassword']);
Route::post('/reset-password', [PasswordResetController::class, 'resetPassword']);

//Protected routes
Route::middleware('auth:sanctum')->group(function () {
    Route::delete('/logout', [AuthController::class, 'logout']);
    Route::get('/user', [AuthController::class, 'user']);
});
Enter fullscreen mode Exit fullscreen mode

API Authentication System Implementation

Postman Automation Script

Postman Environment Variables
-Two key variables configured:

  • url: Base API endpoint for all requests
  • access-token: Authentication token automatically saved after login/register

register

login

logout

OTP

  • send OTP

  • verify OTP

password

  • forgot password

  • reset password

authenticated user

Top comments (0)