Aller au contenu principal

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:

  1. Request — user enters email, clicks Email me a code.
  2. Awaiting code — user enters the 6-character code from their inbox.
  3. 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 ExtsUserSession ledger 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:

VariableValue
userThe recipient ExtsUser
magic_codeThe 6-character code to display prominently
redirect_uriThe URL you passed in the request (handy for a "Click to sign in" button that carries it through)
timeoutMinutes 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 returns invalid_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_ratelimit middleware; 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:

EventSeverityWhen
magic_link_requestinfo/auth/magic-link/request/ called; metadata.sent tells you whether the email actually went out.
magic_link_consume_successinfoA valid code was consumed; metadata.mfa_required indicates whether MFA followed.
magic_link_consume_failurewarnA bad, used, or expired code was presented.

Configuration

SettingDefaultMeaning
MAGIC_LINK_TTL_SECONDS900 (15 min)Code lifetime
VERIFICATION_CODE_TIMEOUT3600Shared 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 }
}