Magic-link Login
Magic-link login lets users sign in with nothing but their email address. SyAuth emails a short one-time code; the user pastes it into your app; we exchange it for the same session tokens a password login would have produced. Ideal for:
- Onboarding flows where forcing a password is friction.
- Breakglass recovery when a password is forgotten.
- Mobile apps that want to avoid in-app password entry.
Implementation is intentionally small: we reuse the existing VerificationCode table under code_type='magic_link'.
Hosted UI
SyAuth ships a ready-made page at /{locale}/magic-link. Deep-link users to it with optional query params:
oauth_client— the client_id of the SyAuth OAuth application making the call (scopes the lookup to the right workspace).redirect_uri— where to send the user after a successful sign-in.
The page handles three states:
- Request — user enters email, clicks Email me a code.
- Awaiting code — user enters the 6-character code from their inbox.
- MFA challenge — if the user has TOTP enrolled, an authenticator-code prompt appears before the final redirect.
API reference
POST /auth/magic-link/request/
Public. Generates a code and emails it. The response is always 200 regardless of whether the email exists — this prevents account enumeration.
{
"email": "[email protected]",
"oauth_client": "abc123", // optional
"redirect_uri": "https://app.example.com/callback" // optional, used in the email template
}
Response:
{ "message": "If that email exists, a login link has been sent." }
The code is stored in VerificationCode with code_type='magic_link', default TTL 15 minutes (MAGIC_LINK_TTL_SECONDS setting).
POST /auth/magic-link/consume/
Public. Validates the code and returns session tokens.
{
"email": "[email protected]",
"code": "ABC123",
"oauth_client": "abc123"
}
Success without MFA (same payload as LoginView):
{
"user": { "...": "..." },
"token": "eyJ…access…",
"refresh_token": "eyJ…refresh…",
"message": "Login successful"
}
Success with MFA required — the caller must finish the flow at /auth/mfa/verify/:
{
"mfa_required": true,
"mfa_ticket": "…",
"mfa_methods": ["totp"]
}
Failure (invalid or expired code): 400 Bad Request with {"error": "Invalid or expired code"}.
A successful consume also:
- marks the user's
email_verified = true(possession of the inbox proves ownership), - updates
last_login, - creates an
ExtsUserSessionledger row (see Sessions).
Email template
Magic-link emails are rendered through the same per-client EmailTemplate pipeline used by verification and password reset. The template type is magic_link; the context exposes:
| Variable | Value |
|---|---|
user | The recipient ExtsUser |
magic_code | The 6-character code to display prominently |
redirect_uri | The URL you passed in the request (handy for a "Click to sign in" button that carries it through) |
timeout | Minutes until the code expires |
Add a magic_link template in the Email Templates section of the dashboard if you want branded copy; otherwise the default transactional template is used.
Security properties
- One-time use.
VerificationCode.mark_as_used()flips the flag atomically on consume; a second attempt returnsinvalid_or_expired. - Short TTL. 15 minutes by default — well below the 1-hour default for password reset.
- No enumeration. The request endpoint never tells a caller whether the email exists.
- Rate limited. Covered by the existing
django_ratelimitmiddleware; add tighter per-email throttling if you expect abuse. - MFA-aware. Users with a confirmed TOTP device are gated the same way as password login, so magic-link does not let an attacker bypass the second factor.
Audit events
Every step is logged to the Audit Log:
| Event | Severity | When |
|---|---|---|
magic_link_request | info | /auth/magic-link/request/ called; metadata.sent tells you whether the email actually went out. |
magic_link_consume_success | info | A valid code was consumed; metadata.mfa_required indicates whether MFA followed. |
magic_link_consume_failure | warn | A bad, used, or expired code was presented. |
Configuration
| Setting | Default | Meaning |
|---|---|---|
MAGIC_LINK_TTL_SECONDS | 900 (15 min) | Code lifetime |
VERIFICATION_CODE_TIMEOUT | 3600 | Shared default used by VerificationCode.generate_code when a specific TTL isn't set |
Client-side integration
Quick example using fetch:
async function startMagicLink(email: string) {
await fetch('/api/auth/magic-link/request/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
// Show "check your inbox" regardless of the response
}
async function finishMagicLink(email: string, code: string) {
const res = await fetch('/api/auth/magic-link/consume/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, code }),
})
const data = await res.json()
if ('mfa_required' in data) return startMfa(data.mfa_ticket)
return data // { user, token, refresh_token }
}