| |

Laravel Customize Email Verification Link – Frontend-based Email Verification Flow

email verified using frontend link customized

Frontend React/Vue: frontendurl.local or 127.0.0.1:3000
Backend Laravel: backendurl.local or 127.0.0.1:8000

I’m using Laravel Breez with install –API

Learn how to modify the Laravel email verification flow to redirect users to a frontend page for email verification, extracting query parameters from the URL and submitting them back to the backend for token validation and user confirmation.

Steps:

- Updated `AuthServiceProvider` to change the default Laravel email verification URL to a frontend-based verification flow.

- The email now contains a URL in the following format:
  https://frontendurl.local/signup/email-verification/verify?id=31&hash=820bb4475714d925fb4300bab1ce9bff6be80471&expires=1736730601&signature=8e2e31124b461b8cf22d48e94f237ac63012a5edb15ab3b5b12a5eb6c06e934b

- On the frontend, extract `id`, `hash`, `expires`, and `signature` from the query string.

- Frontend makes a backend request to:
  http://backendurl.local/api/verify-email/{id}/{hash}?expires={expires}&signature={signature}

- The backend verifies the URL parameters, and if valid, returns a `token` and user object.

- Ensure the token is passed correctly when hitting the backend verification URL.

Note: This change aligns the email verification process with the frontend-first approach, improving security and user experience.

As we know we have

#auth.php routes added by breeze
Route::get('/verify-email/{id}/{hash}', VerifyEmailController::class)
                ->middleware(['auth:sanctum', 'signed', 'throttle:6,1'])
                ->name('verification.verify');
default laravel breeze mail notification

here by default laravel send an email like format http://127.0.0.1:8000/api/verify-email/12/de75dbad3ec50e26796f79c3807f01a04b801a41?expires=1736723751&signature=cde3e0100bb8bbaacadc09577017c0ee044cb5fda80ce83121cb8f51160601cc here `12` is id and…

id -----------> 12
hash ---------> de75dbad3ec50e26796f79c3807f01a04b801a41
expires-------> 1736723751
signature ----> cde3e0100bb8bbaacadc09577017c0ee044cb5fda80ce83121cb8f51160601cc

using above link we can verify the email and even if we add that url in postman a hit a GET request then it will also
work and in postman body we received 'email verified successfull'.

BUT here we need 127.0.0.0:3000 or frontendurl.local website url instead of backend url. 

HOW WE DO THAT.
- extract the id, hash, expires and signature
- need to change the action url in mail notification. and for that we'll modify `\app\ProvidersAuthServiceProvider.php`

Modify AuthServiceProvider.php to change the mail URL

file: app\providers\AuthServiceProvider.php

check laravel official docs verification email customization.

# Add VerifyEmail::toMailUsing... in boot method
VerifyEmail::toMailUsing(function (object $notifiable, string $url) {
    $parsedUrl = parse_url($url);
    $pathSegments = explode('/', trim($parsedUrl['path'], '/'));
    if (count($pathSegments) < 3) {
        throw new \Exception('Invalid URL structure. Expected at least 3 segments.');
    }

    $id = $pathSegments[2]; // The `id` should be the second segment
    $hash = $pathSegments[3]; // The `hash` should be the third segment

    parse_str($parsedUrl['query'], $queryParams);
    if (!isset($queryParams['signature'])) {
        throw new \Exception('Missing signature parameter in URL.');
    }

    $frontendUrl = config('app.frontend_url') . '/signup/email-verification/verify?' . http_build_query([
            'id' => $id,
            'hash' => $hash,
            'expires' => $queryParams['expires'],
            'signature' => $queryParams['signature'],
        ]);

    return (new MailMessage())
        ->subject('Verify Email Address')
        ->line('Click the button below to verify your email address.')
        ->action('Verify Email Address', $frontendUrl);
});

Full file AuthServiceProvider.php

<?php

namespace App\Providers;

use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Notifications\Messages\MailMessage;

class AuthServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $this->registerPolicies();

        VerifyEmail::toMailUsing(function (object $notifiable, string $url) {
            /**
             * https://backendurl.local/api/verify-email/20/6918f05cc8925549c526f3d1076bfaa59fed1657?expires=1736725240&signature=f51b833ddaed57589228a44716ca76b393485b11fb83a499d9d862c96b7a8714
             * to redirect to
             * https://frontendurl.local/signup/email-verification/verify?id=31&hash=820bb4475714d925fb4300bab1ce9bff6be80471?expires=1736730601&signature=8e2e31124b461b8cf22d48e94f237ac63012a5edb15ab3b5b12a5eb6c06e934b
             *
             * Note:
             * check `AuthServiceProvider` method `boot` having `VerifyEmail::toMailUsing(function (object $notifiable, string $url) {...`
             * where we'll change the actual laravel url action link with frontend.
             * and from frontend you need to pick `id, hash, expiry and signature` from query string and hit `http://backendurl.local/api/verify-email/{id}/{hash}?expires=zzZZz&signature=zzZzzz`
             * NOTE: once you'll hit the signup you'll get the `token` as well in response along with user object so, make
             * sure pass that token once you hit the backend `http://backendurl.local/api/verify-email/{id}/{hash}?expires=zzZZz&signature=zzZzzz`
             *
             * Explain:
             * // in email you will received email like
             * // `https://frontendurl.com/signup/email-verification/verify?id=31&hash=820bb4475714d925fb4300bab1ce9bff6be80471&expires=1736730601&signature=8e2e31124b461b8cf22d48e94f237ac63012a5edb15ab3b5b12a5eb6c06e934b`
             * // extract the values like id,hash,expiry,signature and hit url of laravel api
             * //
             * // url format: `http://backendurl.local/api/verify-email/{id}/{hash}?expires={expires}&signature={signature}`
             * // `http://backendurl.pk/api/verify-email/11/279875142cc2d5cde3549e493f0c0e1c45c5281b?expires=1736723592&signature=fed390367d49af874290c1cb26e91bc20c585d22ed9ec47e032bd17b5f301f51`
             * // where
             * // - id: `11`
             * // - `hash`: 279875142cc2d5cde3549e493f0c0e1c45c5281b
             * // - 'expires': 1736723592
             * // - 'signature': fed390367d49af874290c1cb26e91bc20c585d22ed9ec47e032bd17b5f301f51
             * //
             */
            $parsedUrl = parse_url($url);
            $pathSegments = explode('/', trim($parsedUrl['path'], '/'));
            if (count($pathSegments) < 3) {
                throw new \Exception('Invalid URL structure. Expected at least 3 segments.');
            }

            $id = $pathSegments[2]; // The `id` should be the second segment
            $hash = $pathSegments[3]; // The `hash` should be the third segment

            parse_str($parsedUrl['query'], $queryParams);
            if (!isset($queryParams['signature'])) {
                throw new \Exception('Missing signature parameter in URL.');
            }

            $frontendUrl = config('app.frontend_url') . '/signup/email-verification/verify?' . http_build_query([
                    'id' => $id,
                    'hash' => $hash,
                    'expires' => $queryParams['expires'],
                    'signature' => $queryParams['signature'],
                ]);

            return (new MailMessage())
                ->subject('Verify Email Address')
                ->line('Click the button below to verify your email address.')
                ->action('Verify Email Address', $frontendUrl);
        });

        //
    }
}

Once we modify the VerifyEmail::toMailUsing(function (object $notifiable, string $url) { the mail we’l received will be like

verify email action url changed

you can see the action url is changed and we have different query params like id,hash,expires,signature etc. means we can pick these and hit axios or postman hit to laravel endpoint and our email will be verified.

how you can create the Axios code to pick the query parameters and create the URL, including sending the Authorization header with a Bearer token

// url: https://frontendvueapp.local/signup/email-verification/verify?id=35&hash=de9b06e6dcd3b57df54675f5fb42aef9c6b7aa49&expires=1736733681&signature=4899e7502711cc8efead9b04c48e6d08f6d24ea5dc8543a5b20f66dad2d9c42d


import axios from 'axios';

async function sendVerificationRequest() {
  // Extract query parameters from the current URL
  const urlParams = new URLSearchParams(window.location.search);
  const id = urlParams.get('id');
  const hash = urlParams.get('hash');
  const expires = urlParams.get('expires');
  const signature = urlParams.get('signature');

  if (!id || !hash || !expires || !signature) {
    console.error('Missing required query parameters');
    return;
  }

  // Construct the backend URL
  const backendUrl = `https://backendurl.local/api/verify-email/${id}/${hash}?expires=${expires}&signature=${signature}`;

  const token = 'your-bearer-token'; // got when we register the user. check below `AuthWithTokenResource.php` code.

  try {
    const response = await axios.post(backendUrl, null, {
      headers: {
        'Authorization': `Bearer ${token}`,
      },
    });

    console.log('Verification successful:', response.data);
  } catch (error) {
    console.error('Verification failed:', error.response ? error.response.data : error.message);
  }
}

// Call the function to send the request
sendVerificationRequest();

How to in Details

while register a user you need to return a token as well. the reason the route of verify-email had a auth:sanctum middleware, so, while creating a new user return token and pass that token from react/vue side along with email action url link.

#auth.php (routes\auth.php)

// and i include this file in `api.php` using `require __DIR__.'/auth.php';`
// so, don't add auth.php in web.php route file, by default Breeze add auth.php routes in web.php 

...
...
Route::get('/verify-email/{id}/{hash}', VerifyEmailController::class)
                ->middleware(['auth:sanctum', 'signed', 'throttle:6,1'])
                ->name('verification.verify');

Here what I received once a user is registered:

# AuthenticatedSessionController.php (created by Breeze - \app\Http\Controller\Auth)
public function store(LoginRequest $request)
{
    $request->authenticate();
    //$request->session()->regenerate();

    return new AuthWithTokenResource(auth()->user());
}


#AuthWithTokenResource.php (\app\Http\Resources\)
class AuthWithTokenResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'user' => [
                //'id' => $this->id,
                'name' => $this->name,
                'email' => $this->email,
                'email_verified_at' => $this->email_verified_at,
                'password' => $this->password,
                'remember_token' => $this->remember_token,
                'created_at' => $this->created_at,
                'updated_at' => $this->updated_at,
            ],
            'token' => $request->user()->createToken('authToken')->plainTextToken
        ];
    }
{
    "data": {
        "id": 35,
        "name": "Hassam",
        "email": "[email protected]",
        "email_verified_at": null,
        "password": "$2y$10$pgoNcPhdEF9..zpxmPR9su.n48o4lNtcYN9qo9UTHFBBAef2FYaAq",
        "remember_token": null,
        "created_at": "2025-01-13T01:01:21.000000Z",
        "updated_at": "2025-01-13T01:01:21.000000Z",
    },
    "token": "19|tNBodHDHqiBIkVnhWmCSJ49cRSZXjmbXMTBbYMsGa8feb566"
}

Changes we need to do in VerifyEmailController

# \routes\auth.php
Route::get('/verify-email/{id}/{hash}', VerifyEmailController::class)
                ->middleware(['auth:sanctum', 'signed', 'throttle:6,1'])
                ->name('verification.verify');


# VerifyEmailController.php
class VerifyEmailController extends Controller
{
    public function __invoke(EmailVerificationRequest $request): \Illuminate\Http\JsonResponse
    {
        if ($request->user()->hasVerifiedEmail()) {
            return response()->json([
                'status' => false,
                'message' => 'Email already verified.',
                'data' => [
                    'verified' => true
                ],
            ], 200);
            //return redirect()->intended(
            //    config('app.frontend_url').RouteServiceProvider::HOME.'?verified=1'
            //);
        }

        if ($request->user()->markEmailAsVerified()) {
            event(new Verified($request->user()));
        }

        //return redirect()->intended(
        //    config('app.frontend_url').RouteServiceProvider::HOME.'?verified=1'
        //);

        return response()->json([
            'status' => true,
            'message' => 'Email successfully verified.',
            'data' => [
                'verified' => true
            ]
        ], 200);
    }
}

routes\auth.php full file

add routes\auth.php in routes\api.php
require <strong>DIR</strong>.'/auth.php';

<?php

use App\Http\Controllers\Auth\AuthenticatedSessionController;
use App\Http\Controllers\Auth\EmailVerificationNotificationController;
use App\Http\Controllers\Auth\NewPasswordController;
use App\Http\Controllers\Auth\PasswordResetLinkController;
use App\Http\Controllers\Auth\RegisteredUserController;
use App\Http\Controllers\Auth\VerifyEmailController;
use Illuminate\Support\Facades\Route;

Route::post('/register', [RegisteredUserController::class, 'store'])
    ->middleware('api')
    ->name('register');

Route::post('/login', [AuthenticatedSessionController::class, 'store'])
    ->middleware('api')
    ->name('login');

Route::post('/forgot-password', [PasswordResetLinkController::class, 'store'])
    ->middleware('api')
    ->name('password.email');

Route::post('/reset-password', [NewPasswordController::class, 'store'])
    ->middleware('api')
    ->name('password.store');

Route::get('/verify-email/{id}/{hash}', VerifyEmailController::class)
    ->middleware(['auth:sanctum', 'signed', 'throttle:6,1'])
    ->name('verification.verify');

Route::post('/email/verification-notification', [EmailVerificationNotificationController::class, 'store'])
    ->middleware(['auth:sanctum', 'throttle:6,1'])
    ->name('verification.send');

Route::post('/logout', [AuthenticatedSessionController::class, 'destroy'])
    ->middleware('auth:sanctum')
    ->name('logout');

Results

using axios we created below url that is same as Laravel used, just hit that url using fetch or axios from react/vue/angular.

email verified postman ss
email verified using frontend link customized

For Customize the Reset Password Link

file: app\providers\AuthServiceProvider.php

class AuthServiceProvider extends ServiceProvider
{
    protected $policies = [
        // 'App\Models\Model' => 'App\Policies\ModelPolicy',
    ];

    public function boot(): void
    {
        $this->registerPolicies();

        ResetPassword::createUrlUsing(function (object $notifiable, string $token) {
            return config('app.frontend_url')."/password-reset/$token?email={$notifiable->getEmailForPasswordReset()}";
        });


        VerifyEmail::toMailUsing(function (obj...
        ...
        ...
customized reset password link mail

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *