Magic Link Authentication Implementation on Website
Magic Link — passwordless login: user enters email, receives letter with link, clicks — and is authorized. No passwords, no confirmation forms. Popular in SaaS products and developer tools (Notion, Slack, Linear).
How It Works
1. POST /auth/magic-link { email: "[email protected]" }
→ token generation (32 bytes random)
→ save hash(token) to DB/Redis with TTL 15 min
→ send email with link /auth/magic-link/verify?token=...
→ response: { message: "Email sent" }
2. GET /auth/magic-link/verify?token=...&email=...
→ search for token in DB
→ verify: not expired, hash matches
→ one-time use: delete token
→ authorize user
→ redirect to /dashboard
Token Generation and Storage
class MagicLinkService
{
public function sendLink(string $email): void
{
$this->checkRateLimit($email);
$user = User::firstOrCreate(
['email' => $email],
['name' => explode('@', $email)[0], 'email_verified_at' => now()]
);
$token = Str::random(64);
$hashedToken = hash('sha256', $token);
// Invalidate previous tokens for this user
MagicLinkToken::where('user_id', $user->id)->delete();
MagicLinkToken::create([
'user_id' => $user->id,
'token' => $hashedToken,
'expires_at' => now()->addMinutes(15),
]);
Mail::to($email)->send(new MagicLinkMail($user, $token));
}
public function authenticate(string $token, string $email): User
{
$hashedToken = hash('sha256', $token);
$record = MagicLinkToken::where('token', $hashedToken)
->whereHas('user', fn($q) => $q->where('email', $email))
->where('expires_at', '>', now())
->whereNull('used_at')
->firstOrFail();
// One-time use
$record->update(['used_at' => now()]);
return $record->user;
}
}
Migration
Schema::create('magic_link_tokens', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('token', 64)->unique(); // SHA256 hash
$table->timestamp('expires_at');
$table->timestamp('used_at')->nullable();
$table->string('ip_address', 45)->nullable();
$table->string('user_agent')->nullable();
$table->timestamps();
$table->index(['token', 'expires_at']);
});
Token Verification
class MagicLinkController extends Controller
{
public function verify(Request $request): RedirectResponse
{
$request->validate([
'token' => 'required|string|size:64',
'email' => 'required|email',
]);
try {
$user = $this->magicLinkService->authenticate(
$request->token,
$request->email
);
} catch (ModelNotFoundException) {
return redirect('/login?error=invalid_link');
}
Auth::login($user, remember: true);
// Verify email on first login
if (!$user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
}
return redirect()->intended('/dashboard');
}
}
Rate Limiting
RateLimiter::for('magic-link', function (Request $request) {
return [
Limit::perMinutes(5, 1)->by('email:' . $request->email), // 1 per 5 minutes
Limit::perHour(5)->by('ip:' . $request->ip()), // 5 per hour per IP
];
});
Email Template
Important email details:
- "Sign In" button with token in href
- Expiration time (15 minutes)
- If not requested — ignore
- Text version with full link (for email clients without HTML)
class MagicLinkMail extends Mailable
{
use Queueable, SerializesModels;
public string $loginUrl;
public function __construct(User $user, string $token)
{
$this->loginUrl = URL::signedRoute(
'auth.magic-link.verify',
['token' => $token, 'email' => $user->email],
now()->addMinutes(15)
);
}
public function build(): self
{
return $this->subject('Your Sign In Link')
->markdown('emails.magic-link');
}
}
Security
Several nuances often overlooked:
- Token in URL — logged in server access_log. HTTPS is mandatory
- One token at a time — when requesting new token, invalidate old ones
- IP and User-Agent — save for audit, but don't use for blocking (user may open email on different device)
- Reuse — after click, token is marked as used, not deleted — for detecting retry attempts
Timeline
| Stage | Time |
|---|---|
| Service generation + storage | 1 day |
| Verification + rate limiting | 0.5 day |
| Email template + queue | 0.5 day |
| Frontend form + UI states | 0.5 day |
| Tests + edge cases | 0.5 day |
Total: 3–4 working days.







