Olympus Docs
CookbookAuth flows

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

AspectMagic linkPassword
SecurityEmail account is the bottleneckPassword + (optional) MFA
UXTwo-step (request, click)One-step
Account takeover via email breachEasyHarder (need password + email)
Mobile UXAnnoying (app → mail → app)Smooth
Password reuseNot possibleCommon 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: 15m

Disable password if magic-link is the only method:

selfservice:
  methods:
    password:
      enabled: false

Hera 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:

  1. Looks up identity by email.
  2. Generates 32-byte token.
  3. HMAC-hashes it.
  4. Stores hash in identity_recovery_codes (or new code-flow table).
  5. 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: session

session 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: 15m

Plus 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 enrolled

After 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:

  1. User on desktop requests link.
  2. Email arrives on phone.
  3. User opens phone email, clicks link.
  4. Phone signed in.
  5. 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).

On this page