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.
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.