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


¿Qué es Laravel y por qué usarlo en 2025?

En un mundo donde la tecno


5 estrategias de marketing digital para generar tráfico y ventas

La revolución digital ha conquistado al


Las 10 principales plataformas de música libre de derechos

Producir excelentes videos/películas re


Cómo comenzar con un boletín informativo por email para su tienda de comercio electrónico

Por qué todas las tiendas de comercio e


Como conseguir mas leads en Facebook Ads

La mejor configuración para Facebook Ad


Como atraer trafico de calidad a tu web

&nbsp;Centrarse en generar más tráfico