Passwordless magic link login
Email-link auth with no password at all
A "magic link" flow: user enters email, gets a one-time link, clicks it → signed in. No password to remember (or leak).
Trade-offs vs password
| Aspect | Magic link | Password |
|---|---|---|
| Security | Email account is the bottleneck | Password + (optional) MFA |
| UX | Two-step (request, click) | One-step |
| Account takeover via email breach | Easy | Harder (need password + email) |
| Mobile UX | Annoying (app → mail → app) | Smooth |
| Password reuse | Not possible | Common problem |
For most B2C: magic link is OK but not great. For B2B: usually paired with MFA or as a recovery-only path.
Configuration
Kratos supports magic links via the code method (single-use code, sent via email):
# kratos.yml
selfservice:
methods:
code:
enabled: true
config:
lifespan: 15mDisable password if magic-link is the only method:
selfservice:
methods:
password:
enabled: falseHera UI
Hera's login page detects code is enabled and shows:
[Email input]
[Send sign-in link button]User submits → email arrives with https://your-domain/login?token=....
Click → Kratos validates → session created.
Implementation
User enters email:
await kratos.updateLoginFlow({
flow: flowId,
updateLoginFlowBody: {
method: "code",
identifier: "user@example.com",
},
});Kratos:
- Looks up identity by email.
- Generates 32-byte token.
- HMAC-hashes it.
- Stores hash in
identity_recovery_codes(or new code-flow table). - Sends email with
https://your-domain/login?flow=<flowid>&code=<token>.
User clicks → Kratos validates → session.
If no identity matches the email, Kratos still returns 200 (no enumeration), but no email is sent.
Sign up vs sign in
For code method, sign-up is similar: user enters email, gets a magic link, clicks → account is created and they're signed in.
Configure registration similarly:
selfservice:
flows:
registration:
enabled: true
after:
code:
hooks:
- hook: sessionsession hook auto-creates a session right after registration.
Email template
Subject: Your sign-in link
Hi,
Click below to sign in to [Your App]:
{{ .RecoveryURL }}
This link expires in 15 minutes. If you didn't request this, ignore this email.
[Your App]Rate limiting
Don't let attackers spam the magic-link endpoint:
selfservice:
methods:
code:
config:
attempts: 3 # max attempts per flow
lifespan: 15mPlus rate-limit at Caddy:
@magic_link path /self-service/login
rate_limit @magic_link {
zone magic-link
events 5
window 5m
}5 attempts per 5 minutes per IP. Excessive → 429.
MFA on top
Magic link alone is single-factor (email possession). For sensitive accounts:
selfservice:
methods:
totp:
enabled: true
session:
required_aal: highest_available # forces MFA if enrolledAfter magic-link clicks: if user has TOTP enrolled, they're prompted for TOTP code. AAL2 reached.
"Continue on this device"
Magic-link UX pain point: clicking the email link in mobile mail opens a webview, leaves the user signed in there but not in their browser.
Workaround:
- User on desktop requests link.
- Email arrives on phone.
- User opens phone email, clicks link.
- Phone signed in.
- Desktop is still not signed in.
Fix: include both code AND link in email. User can:
- Click link (signs them in on the device clicked).
- Type code into the original device (signs in on that device).
Click this link to sign in:
{{ .RecoveryURL }}
OR enter this code on the device you started signing in:
{{ .Code }}Code-based flow signs them in on the device that started it. UX win.
Stolen device, stolen email
If attacker has the user's email account access, they have the user. No defense from magic-link alone.
For high-stakes accounts: pair with WebAuthn / hardware key (not magic-link).
Hera customization
Default Hera's code flow may need styling. The "check your email" page benefits from:
- Visible email address (so user remembers which inbox to check).
- "Didn't receive? Resend after 60s" button.
- "Use a different method" link (if you have password too).