Skip to content

Email OTP login in Laravel

Add 6-digit email code sign-in to Laravel with the HTTP client, the session, throttle middleware and Auth::login, and handle expiry, tries and rate limits.

OTPBy Rushi Patel, Senior iOS developerUpdated 6 November 20267 min read

Laravel has password sign-in and authenticator-app two-factor out of the box, but no 6-digit code by email. This post adds one with a controller, two routes and the session, using Laravel’s HTTP client and the SignOps email OTP API, which makes the code, sends it from your domain and checks it.

The flow

Step Route What happens
1. User types an email GET /login A form posts to otp.send
2. Send the code POST /login/email POST /v1/otp/send, otp_id put in the session
3. User types the 6 digits GET /login/code A form posts to otp.verify
4. Check the code POST /login/code POST /v1/otp/verify with the session’s otp_id
5. Sign in same Auth::login() only when valid is true

Setup

# .env
SIGNOPS_KEY=sk_test_...
// config/services.php
'signops' => [
    'key' => env('SIGNOPS_KEY'),
],

Use the test key while building: test codes go to the test addresses you list in the dashboard, so the whole flow works before you add DNS records.

Routes

// routes/web.php
use App\Http\Controllers\EmailOtpController;

Route::view('/login', 'auth.email')->name('login');
Route::post('/login/email', [EmailOtpController::class, 'send'])
    ->middleware('throttle:5,1')->name('otp.send');
Route::view('/login/code', 'auth.code')->name('otp.code');
Route::post('/login/code', [EmailOtpController::class, 'verify'])
    ->middleware('throttle:10,1')->name('otp.verify');

The controller

<?php
// app/Http/Controllers/EmailOtpController.php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;

class EmailOtpController extends Controller
{
    private function signops(string $path, array $body)
    {
        return Http::withToken(config('services.signops.key'))
            ->acceptJson()
            ->timeout(10)
            ->post("https://api.signops.dev/v1{$path}", $body);
    }

    public function send(Request $request)
    {
        $data = $request->validate(['email' => ['required', 'email']]);

        $res = $this->signops('/otp/send', [
            'email' => $data['email'],
            'app_name' => config('app.name'),
            'ip' => $request->ip(),
        ]);

        if ($res->failed()) {
            return back()->withInput()
                ->withErrors(['email' => $res->json('error.message')]);
        }

        $request->session()->put('otp_id', $res->json('otp_id'));

        return redirect()->route('otp.code');
    }

    public function verify(Request $request)
    {
        $data = $request->validate(['code' => ['required', 'digits:6']]);
        $otpId = $request->session()->get('otp_id');
        if (! $otpId) {
            return redirect()->route('login');
        }

        $res = $this->signops('/otp/verify', ['otp_id' => $otpId, 'code' => $data['code']]);

        if ($res->failed()) {
            $request->session()->forget('otp_id');
            return redirect()->route('login')
                ->withErrors(['email' => $res->json('error.message')]);
        }
        if (! $res->json('valid')) {
            return back()->withErrors([
                'code' => 'Wrong code. Tries left: '.$res->json('tries_left'),
            ]);
        }

        $request->session()->forget('otp_id');
        $email = $res->json('email');
        $user = User::firstOrCreate(
            ['email' => $email],
            ['name' => Str::before($email, '@'), 'password' => Str::random(40)],
        );

        Auth::login($user, remember: true);
        $request->session()->regenerate();

        return redirect()->intended('/');
    }
}

Three details matter. The email comes from the verify answer, not from the form, because it is the address the code went to. $request->ip() is the user’s address only if TrustProxies is set up for your load balancer. And regenerate() gives the signed-in user a new session id.

The code form

{{-- resources/views/auth/code.blade.php --}}
<form method="POST" action="{{ route('otp.verify') }}">
    @csrf
    <label for="code">The 6-digit code we emailed you</label>
    <input id="code" name="code" inputmode="numeric" autocomplete="one-time-code"
           pattern="\d{6}" maxlength="6" required autofocus>
    @error('code') <p role="alert">{{ $message }}</p> @enderror
    <button>Sign in</button>
</form>

What can come back

Answer Meaning What to show
200, valid: true The code is right; it cannot be used again Sign the user in
200, valid: false Wrong code, tries_left says how many remain The error under the field
429 rate_limited Sent too soon or too often; the Retry-After header has the seconds Wait before resending
410 expired Past 10 minutes, or the email bounced “Send a new code”
422 too_many_tries Five wrong codes “Send a new code”
403 domain_required Live key, domain not verified yet Fix in the dashboard; test keys work meanwhile

Errors come back as {"error": {"code": "...", "message": "..."}}, so $res->json('error.message') is a sentence you can show.

With SignOps

SignOps Email OTP makes the code, keeps only a hash of it, sends it from your own domain and checks it: 10 minutes, 5 tries, a 30-second resend wait and hourly limits per address and per IP. 500 codes are free to try, then $1 per 1,000 codes. Starter includes 2,000 a month. The dashboard setup is in the email OTP guide; the same flow in Next.js is in Next.js email OTP.

Sources: Laravel: HTTP client, Laravel: authentication, Laravel: rate limiting routes, SignOps API docs.

Frequently asked questions

More questions? Email us and the answer goes on this page.

hello@signops.dev
Does Laravel have email OTP built in?

No. Laravel's own authentication signs in with a password, and Fortify adds TOTP two-factor from an authenticator app. A 6-digit code by email is something you add, either with your own table and mailer or with an API that makes, sends and checks the code.

Where should the otp_id be kept in Laravel?

In the session. The send action puts it there and the verify action reads it, so the browser only ever posts the 6 digits.

How do I stop people using my form to email strangers?

Put the throttle middleware on the send route, pass the user's IP to the API so it limits per address and per IP, and add a check such as Cloudflare Turnstile to the form.

How long does the code last?

With SignOps, 10 minutes and 5 tries. A resend to the same address waits 30 seconds, and one address gets at most 5 codes an hour by default.

Send your first push today.

Free for 1,000 push devices, 100 approvals and 1,000 passkey sign-ins a month, and 500 email codes to try. No card until you pass a free amount.