
September 07, 2026
11 min read
By Kokil Thapa | Last reviewed: September 2026
Users forget passwords. Support tickets pile up. Reset flows break on mobile. Laravel passwordless login with magic links solves this by emailing a one-time, time-limited URL that logs the user in after a single click. On client portals and booking apps I have shipped with custom Laravel software, this pattern cut login friction without weakening security when tokens are signed, hashed, and short-lived. This guide walks through a complete implementation you can drop into Laravel 13 on PHP 8.3 or higher.
What Is Laravel Passwordless Login with Magic Links?
Magic-link authentication replaces the password step with email verification. The user submits an email address. Your app creates a random token, stores a hash of it, and sends a link. When the user opens the link, Laravel validates the token, marks it used, and logs them in.
This is not the same as "Login with Google." OAuth delegates identity to a third party. Magic links keep auth inside your app and your mail pipeline. You control expiry, rate limits, and audit logs.
Common use cases include:
- Client portals where users log in rarely, such as law-firm document portals
- Lead follow-up flows on legal information sites
- Internal admin tools for small teams
- Checkout or booking confirmation pages that need a fast return path
The pattern fits well alongside Sanctum for API tokens. Web users get magic links. Mobile or SPA clients can still use token-based auth on separate routes.
How Do You Build Magic Link Authentication in Laravel 13?
Start with a migration for login tokens. Never store the raw token in the database. Hash it with SHA-256, the same approach Laravel uses for remember tokens.
Step 1: Create the login_tokens table
php artisan make:migration create_login_tokens_table
public function up(): void
{
Schema::create('login_tokens', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('token_hash', 64)->unique();
$table->timestamp('expires_at');
$table->timestamp('used_at')->nullable();
$table->string('ip_address', 45)->nullable();
$table->timestamps();
$table->index(['user_id', 'expires_at']);
});
} Step 2: Add a LoginToken model
namespace App\Models;
class LoginToken extends Model
{
protected $fillable = [
'user_id', 'token_hash', 'expires_at', 'used_at', 'ip_address',
];
protected function casts(): array
{
return [
'expires_at' => 'datetime',
'used_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isValid(): bool
{
return is_null($this->used_at) && $this->expires_at->isFuture();
}
} Step 3: Create a service to issue tokens
namespace App\Services;
use App\Models\LoginToken;
use App\Models\User;
use Illuminate\Support\Str;
class MagicLinkService
{
public function createForUser(User $user, ?string $ip = null): string
{
LoginToken::where('user_id', $user->id)
->whereNull('used_at')
->delete();
$plainToken = Str::random(64);
LoginToken::create([
'user_id' => $user->id,
'token_hash' => hash('sha256', $plainToken),
'expires_at' => now()->addMinutes(15),
'ip_address' => $ip,
]);
return $plainToken;
}
public function verify(string $plainToken): ?User
{
$record = LoginToken::where('token_hash', hash('sha256', $plainToken))
->first();
if (! $record || ! $record->isValid()) {
return null;
}
$record->update(['used_at' => now()]);
return $record->user;
}
} Deleting unused tokens before creating a new one enforces one active link per user. That reduces the window if someone requests multiple emails.
Step 4: Wire routes and controllers
// routes/web.php
Route::middleware('guest')->group(function () {
Route::get('/login/magic', [MagicLinkController::class, 'create'])
->name('login.magic.create');
Route::post('/login/magic', [MagicLinkController::class, 'store'])
->middleware('throttle:5,1')
->name('login.magic.store');
Route::get('/login/magic/{token}', [MagicLinkController::class, 'verify'])
->middleware('signed')
->name('login.magic.verify');
}); public function store(MagicLinkRequest $request, MagicLinkService $service): RedirectResponse
{
$user = User::where('email', $request->validated('email'))->first();
if ($user) {
$token = $service->createForUser($user, $request->ip());
$user->notify(new MagicLinkNotification($token));
}
return back()->with('status', 'If that email exists, we sent a login link.');
} Always return the same message whether the email exists or not. This prevents account enumeration. The same rule applies to password reset flows in the Laravel password reset documentation.
Step 5: Send the notification
namespace App\Notifications;
class MagicLinkNotification extends Notification
{
public function __construct(private string $token) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$url = URL::temporarySignedRoute(
'login.magic.verify',
now()->addMinutes(15),
['token' => $this->token]
);
return (new MailMessage)
->subject('Your login link')
->line('Click below to sign in. This link expires in 15 minutes.')
->action('Sign in', $url)
->line('If you did not request this, ignore this email.');
}
} Queue the notification in production. Use ShouldQueue on the notification class and a working queue driver. I have seen magic-link login fail silently because mail sat in the sync driver under load on a shared server.
How Does Magic Link Login Compare to Password and OAuth?
Pick the auth method based on user behaviour and threat model. Magic links trade password fatigue for email dependency.
| Method | User friction | Security notes | Best fit |
|---|---|---|---|
| Password | Medium — must remember credentials | Weak passwords, reuse, phishing | High-frequency daily users |
| Magic link | Low — one click from email | Email account becomes the gate; link interception risk | Infrequent portal access, B2B clients |
| OAuth (Google, etc.) | Low if user has provider account | Third-party dependency, privacy concerns | Consumer apps, broad audiences |
| Passkeys / WebAuthn | Low after setup | Strong crypto; browser support varies | Security-first apps with modern browsers |
For a legal-tech portal where clients upload documents once a month, magic links often beat forcing another password. For a daily admin dashboard, passwords plus optional 2FA may still win. Read Laravel policies and gates to lock down what happens after login regardless of method.
Hybrid setups work well. Offer magic link as primary and keep password login behind a "Use password instead" link. Laravel 12 and 13 still ship full password auth out of the box, so you are adding a path rather than replacing the stack.
What Security Mistakes Break Magic Link Login?
Magic links fail in production when teams treat them like shareable URLs. They are credentials. Handle them accordingly.
Rate limiting and abuse prevention
Apply throttle middleware on the request endpoint. Five attempts per minute per IP is a sensible starting point. Also throttle the verify route to slow brute-force guessing of token values.
Route::get('/login/magic/{token}', [MagicLinkController::class, 'verify'])
->middleware(['signed', 'throttle:10,1'])
->name('login.magic.verify'); HTTPS and signed URLs
Never send magic links over HTTP in production. Configure APP_URL with https://. Laravel signed routes add a tamper-proof signature. If someone edits the token parameter, verification fails before your controller runs.
The Laravel signed URL docs explain how expiry is embedded in the signature. Match your signed-route TTL to your database token expiry.
Session fixation and regeneration
After a successful verify, regenerate the session ID. Laravel does this automatically when you call Auth::login($user) if session regeneration is enabled in your auth config.
public function verify(string $token, MagicLinkService $service): RedirectResponse
{
$user = $service->verify($token);
if (! $user) {
return redirect()->route('login.magic.create')
->withErrors(['token' => 'This login link is invalid or expired.']);
}
Auth::login($user, remember: true);
return redirect()->intended(route('dashboard'));
} Logging and monitoring
Log magic-link requests without storing the plain token. Record user ID, IP, and timestamp. Alert on spikes. Pair this with the same monitoring you use for database transaction failures and queue backlogs.
How Do You Test and Deploy Magic Links in Production?
Local development with Mailpit or Mailhog catches template bugs early. Production needs queue workers, SPF/DKIM records, and a rollback plan.
Feature tests
public function test_magic_link_logs_user_in(): void
{
Notification::fake();
$user = User::factory()->create();
$this->post(route('login.magic.store'), ['email' => $user->email])
->assertSessionHas('status');
Notification::assertSentTo($user, MagicLinkNotification::class);
} Use factories as described in Laravel seeders vs factories. Extract the token in a notification test by capturing the mailable URL, then hit the verify route and assert authentication.
Mail deliverability
Magic-link login lives or dies on email delivery. Configure SPF, DKIM, and DMARC on your sending domain. Use a transactional provider rather than the host's default SMTP when possible. Costs run roughly Rs 1,500–5,000/month (~USD 11–37) for modest volume on most providers.
Deployment checklist
- Set
QUEUE_CONNECTION=databaseor Redis and run a queue worker via Supervisor - Confirm
APP_URLmatches your production domain with HTTPS - Run migrations for
login_tokensbefore switching traffic - Add a scheduled command to prune expired tokens older than 48 hours
- Reload PHP-FPM after deploy so opcache picks up changes, as covered in GitLab CI deploy pipelines for Laravel
// routes/console.php
Schedule::command('model:prune', [
'--model' => [LoginToken::class],
])->daily(); Add use Illuminate\Database\Eloquent\Prunable; to the model with a prunable() query scoped to expired records. This keeps the table small on high-traffic apps.
Blade login form
Keep the UI simple. One email field beats a cluttered auth page. Generate strong internal tokens with a password generator tool during development, but never expose that pattern to end users for login.
For ongoing reliability, pair this with Laravel support and maintenance so queue workers and mail DNS stay healthy after launch.
Key Takeaways
- Hash tokens with SHA-256 in the database; never store or log the plain value.
- Combine signed URLs with a 15-minute expiry and single-use
used_atcolumn. - Return identical messages for existing and non-existing emails to block enumeration.
- Queue mail notifications and run throttle middleware on both request and verify routes.
- Regenerate sessions on login and prune expired tokens on a daily schedule.
- Magic links suit infrequent-access portals; keep password or OAuth as fallback options.
People Also Ask
Are magic links secure enough for production apps?
Yes, when tokens are hashed, single-use, time-limited, and sent over HTTPS inside signed URLs. The email inbox becomes the authentication factor, so encourage users to protect email with its own 2FA. Follow guidance from the OWASP forgot-password cheat sheet for enumeration and rate-limit rules.
Can magic links work alongside normal Laravel password login?
Absolutely. Add magic-link routes under the guest middleware without removing Fortify or Breeze password routes. Many apps show email-first login and hide password auth behind a secondary link.
What Laravel version do you need for this pattern?
Laravel 12 or 13 on PHP 8.2 or higher works. Laravel 13 requires PHP 8.3 minimum. Signed URLs, notifications, and session auth are core features — no extra package is required unless you want a pre-built solution.
How long should a magic link stay valid?
Fifteen minutes is the industry default and a good balance. Support-heavy portals can stretch to 30 minutes. Never exceed one hour; longer windows increase risk if email is forwarded or left open on shared devices.
Ship Passwordless Login Without Cutting Corners
Laravel passwordless login with magic links is a small amount of code with a large UX payoff when you enforce hashing, expiry, rate limits, and queued mail. Start with the migration and service class above, add feature tests, then validate deliverability on staging before you touch production traffic. If you want this wired into a client portal, booking flow, or enterprise Laravel application, contact us — or browse the portfolio for portals that already run on similar auth patterns. For broader architecture context, see modern Laravel architecture best practices and building RESTful APIs with Laravel when your app serves both web and mobile clients.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

