Laravel Magic Link: Login sin contraseña (passwordless) paso a paso

Inicio   /   Laravel Magic Link: Login sin contraseña (passwordless) paso a paso

Blog Laravel Magic Link: Login sin contraseña (passwordless) paso a paso


Laravel Magic Link: Login sin contraseña (passwordless) paso a paso


Implementa un flujo de acceso por enlace mágico en Laravel con expiración, un solo uso y prácticas de seguridad.

¿Qué es un Magic Link?

Un Magic Link es un enlace temporal y de un solo uso que enviamos al correo del usuario. Al abrirlo, iniciamos sesión sin contraseña. Es ideal para mejorar UX y reducir fricción, manteniendo controles de seguridad adecuados.

Arquitectura del flujo

  1. El usuario ingresa su email y solicita acceso.
  2. Generamos un token seguro, lo guardamos con expiración y lo enviamos por correo en un enlace firmado.
  3. El usuario hace clic en el enlace; validamos token, expiración y un solo uso.
  4. Si todo está ok, lo autenticamos y consumimos el token.
Requisitos: Laravel 10/11, cola de correos opcional (recomendado), tabla users con campo email.

1) Migración para tokens de Magic Link

Crearemos una tabla para guardar tokens hash, expiración y uso único.

php artisan make:migration create_magic_links_table
public function up(): void
{
    Schema::create('magic_links', function (Blueprint $table) {
        $table->id();
        $table->foreignId('user_id')->constrained()->cascadeOnDelete();
        $table->string('token_hash', 64); // sha256
        $table->timestamp('expires_at');
        $table->timestamp('consumed_at')->nullable();
        $table->string('ip')->nullable();
        $table->string('user_agent')->nullable();
        $table->timestamps();

        $table->index(['user_id', 'token_hash']);
    });
}

2) Modelo Eloquent

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class MagicLink extends Model
{
    protected $fillable = [
        'user_id','token_hash','expires_at','consumed_at','ip','user_agent'
    ];

    protected $casts = [
        'expires_at' => 'datetime',
        'consumed_at' => 'datetime',
    ];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function isExpired(): bool
    {
        return now()->greaterThan($this->expires_at);
    }

    public function isConsumed(): bool
    {
        return ! is_null($this->consumed_at);
    }
}

3) Rutas

// routes/web.php
use App\Http\Controllers\Auth\MagicLinkController;
use Illuminate\Support\Facades\Route;

Route::middleware('guest')->group(function () {
    Route::get('/magic-login', [MagicLinkController::class, 'showRequestForm'])->name('magic.show');
    Route::post('/magic-login', [MagicLinkController::class, 'sendLink'])
        ->middleware(['throttle:5,1']) // 5 intentos por minuto
        ->name('magic.send');

    Route::get('/magic-login/verify', [MagicLinkController::class, 'verify'])
        ->name('magic.verify'); // enlace del email
});

Route::post('/logout', function () {
    auth()->logout();
    return redirect('/')->with('status', 'Sesión cerrada');
})->name('logout');

4) Controlador

<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Mail\MagicLoginLinkMail;
use App\Models\MagicLink;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;

class MagicLinkController extends Controller
{
    public function showRequestForm()
    {
        return view('auth.magic-login'); // crea una vista simple con campo email
    }

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

        $user = User::where('email', $data['email'])->first();
        // Para no filtrar existencia de usuarios, devolvemos siempre OK
        if (! $user) {
            return back()->with('status', 'Si el correo existe, enviaremos un enlace.');
        }

        // Genera token y guarda hash
        $rawToken = Str::random(40); // devuelve al usuario solo en URL
        $tokenHash = hash('sha256', $rawToken);

        $magic = MagicLink::create([
            'user_id'   => $user->id,
            'token_hash'=> $tokenHash,
            'expires_at'=> now()->addMinutes(15),
            'ip'        => $request->ip(),
            'user_agent'=> substr((string) $request->userAgent(), 0, 255),
        ]);

        // Construye URL: /magic-login/verify?token=...&email=...
        $url = route('magic.verify', [
            'token' => $rawToken,
            'email' => $user->email,
        ]);

        // Opcional: firmar o encriptar parámetros si deseas mayor protección
        Mail::to($user->email)->send(new MagicLoginLinkMail($url));

        return back()->with('status', 'Si el correo existe, enviaremos un enlace.');
    }

    public function verify(Request $request)
    {
        $request->validate([
            'token' => ['required','string'],
            'email' => ['required','email'],
        ]);

        $user = User::where('email', $request->email)->firstOrFail();

        $tokenHash = hash('sha256', (string) $request->token);

        $magic = MagicLink::where('user_id', $user->id)
            ->where('token_hash', $tokenHash)
            ->latest()
            ->first();

        // Validaciones de seguridad
        if (! $magic) {
            return redirect()->route('magic.show')->withErrors(['token' => 'Token inválido.']);
        }
        if ($magic->isExpired()) {
            return redirect()->route('magic.show')->withErrors(['token' => 'El enlace expiró.']);
        }
        if ($magic->isConsumed()) {
            return redirect()->route('magic.show')->withErrors(['token' => 'El enlace ya fue usado.']);
        }

        // Marca como usado (un solo uso)
        $magic->forceFill(['consumed_at' => now()])->save();

        // Autentica al usuario
        auth()->login($user, true); // "remember" opcional

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

5) Mailable para enviar el enlace

php artisan make:mail MagicLoginLinkMail --markdown=emails.magic-link
<?php

namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;

class MagicLoginLinkMail extends Mailable
{
    use Queueable, SerializesModels;

    public string $url;

    public function __construct(string $url)
    {
        $this->url = $url;
    }

    public function build()
    {
        return $this->subject('Tu enlace de acceso')
            ->markdown('emails.magic-link');
    }
}

Plantilla Markdown: resources/views/emails/magic-link.blade.php

@component('mail::message')
# Acceso sin contraseña

Haz clic en el siguiente botón para iniciar sesión:

@component('mail::button', ['url' => $url])
Iniciar sesión
@endcomponent

Este enlace expira en 15 minutos y puede usarse una sola vez.

Si no solicitaste este enlace, puedes ignorar este correo.

Saludos,
{{ config('app.name') }}
@endcomponent

6) Vista sencilla para solicitar el enlace

resources/views/auth/magic-login.blade.php

<!doctype html>
<html lang="es">
<head><meta charset="utf-8"><title>Magic Login</title></head>
<body>
    <h1>Acceso sin contraseña</h1>
    @if ($errors->any())
        <div>@foreach($errors->all() as $e) <p>{{ $e }}</p> @endforeach</div>
    @endif
    @if (session('status')) <p>{{ session('status') }}</p> @endif

    <form method="post" action="{{ route('magic.send') }}">
        @csrf
        <input type="email" name="email" placeholder="tu@email.com" required>
        <button type="submit">Enviar enlace</button>
    </form>
</body>
</html>

7) Seguridad y buenas prácticas

  • Expiración corta: 10–15 minutos.
  • Un solo uso: marca el token como consumido antes o al mismo tiempo que autenticas.
  • Hashea tokens: guarda solo sha256 en DB, nunca el token plano.
  • Throttling: limita solicitudes por IP/email (throttle middleware).
  • Dominios/URLs confiables: envía enlaces con tu dominio y verifica app.url.
  • Auditoría: guarda IP y user-agent para trazabilidad.
  • Opcional: firma la URL con URL::signedRoute() o cifra parámetros sensibles.

8) Opcional: URL firmada

Puedes firmar la URL para evitar manipulación de parámetros:

// Generar URL firmada
$url = URL::temporarySignedRoute(
    'magic.verify',
    now()->addMinutes(15),
    ['token' => $rawToken, 'email' => $user->email]
);

// En el controlador verify, añade el middleware 'signed' en la ruta
// Route::get('/magic-login/verify', ...)->middleware('signed');

Tags: Laravel, mysql, magic, link,

Ultimas Noticias


Cómo escribir una buena publicación de blog: guía paso a paso para principiantes

Las palabras juntas tienen un gran valor


¿Cómo los servicios de redes sociales son beneficiosos para su negocio?

Cuando se trata de generar rentabilidad,


Obtener la URL en Laravel

La fachada Request la podemos usar direc


Software CRM de comercio electrónico: 7 de las mejores soluciones

El software de gestión de relaciones co


7 consejos para crear un sitio web impresionante sin fines de lucro

Hemos compilado una lista de consejos pa


Cómo hacer jabón y venderlo en línea

Un negocio de fabricación de jabón en