Реализация Magic Link авторизации на сайте
Magic Link — вход без пароля: пользователь вводит email, получает письмо со ссылкой, кликает — и авторизован. Никаких паролей, никаких форм с подтверждением. Популярен в SaaS-продуктах и инструментах для разработчиков (Notion, Slack, Linear).
Принцип работы
1. POST /auth/magic-link { email: "[email protected]" }
→ генерация токена (32 байта random)
→ сохранение hash(токен) в БД/Redis с TTL 15 мин
→ отправка письма со ссылкой /auth/magic-link/verify?token=...
→ ответ: { message: "Письмо отправлено" }
2. GET /auth/magic-link/verify?token=...&email=...
→ поиск токена в БД
→ верификация: не истёк, совпадает hash
→ одноразовое использование: удалить токен
→ авторизация пользователя
→ редирект на /dashboard
Генерация и хранение токена
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);
// Инвалидировать предыдущие токены для этого пользователя
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();
// Одноразовое использование
$record->update(['used_at' => now()]);
return $record->user;
}
}
Миграция
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']);
});
Верификация токена
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);
// Подтвердить email при первом входе
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 в 5 минут
Limit::perHour(5)->by('ip:' . $request->ip()), // 5 в час с IP
];
});
Шаблон письма
Важные детали письма:
- Кнопка «Войти» с токеном в href
- Срок действия (15 минут)
- Если не запрашивали — игнорировать
- Текстовая версия с полной ссылкой (для почтовых клиентов без 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('Ваша ссылка для входа')
->markdown('emails.magic-link');
}
}
Безопасность
Несколько нюансов, которые часто упускают:
- Токен в URL — логируется в access_log сервера. Использовать HTTPS обязательно
- Один токен за раз — при запросе нового токена старые инвалидировать
- IP и User-Agent — сохранять для аудита, но не использовать для блокировки (пользователь мог открыть письмо на другом устройстве)
- Переиспользование — после клика токен помечается как использованный, не удаляется — для детектирования повторных попыток
Сроки работ
| Этап | Время |
|---|---|
| Сервис генерации + хранение | 1 день |
| Верификация + rate limiting | 0.5 дня |
| Шаблон письма + очередь | 0.5 дня |
| Frontend форма + UI состояний | 0.5 дня |
| Тесты + edge cases | 0.5 дня |
Итого: 3–4 рабочих дня.







