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 implementar la confirmación de eliminación en Laravel CRUD

La confirmación de eliminación es impo


Primeros 5 Pasos para Mejorar el SEO de tu Web

Si deseas iniciar la mejora del SE


Obtener la URL en Laravel

La fachada Request la podemos usar direc


¿Por qué es necesario tener una página web en la era de las redes sociales?

En un mundo dominado por las r


Las 5 características principales de Laravel Framework

Laravel es un brillante ejemplo de excel


8 pasos para aumentar sus ventas en linea

El mundo del comercio electrónico