TILLAUTH · FLOWS

Magic links — bound, brief, single-use.

Magic links are the most-stolen credential on the internet because they live in inboxes. TillAuth's links are bound to the requesting IP /24, expire in 15 minutes, and are single-use. Forwarding the email gets you nowhere.

What's actually in the link

  • Token: 32 random bytes, base64url. Only sha256(token) is stored.
  • TTL: 15 minutes — enough for a real user to read the email, short enough that a leaked link is dead before anyone gets to it.
  • Single-use: on the first /v1/magic/consume hit, the row's used_at is filled inside the same transaction that mints the session. Replay is a 410 Gone.
  • IP /24 binding: we store the prefix of the requester's IP at /send time; consume must come from the same prefix. A forwarded link that opens in a different network refuses to log in. Carrier-NAT roaming inside the same prefix still works.
  • Redirect allow-list: the optional redirect_uri is checked against the app's allowed_redirect_origins. Mismatch = the link refuses to consume.

Send

POST /v1/magic/send with { email, redirect_uri? }:

  1. Rate limit per IP and per (app, email) — no email-bombing surface.
  2. Mint the token. Store sha256(token), ip_prefix = ip/24, redirect_uri, 15-minute TTL.
  3. Send the email via your configured provider (Resend by default), using the per-app branding template.
  4. Always respond 204 — never reveal whether the email matched a real user. Audit row: signin.magic.sent.

Consume

POST /v1/magic/consume with { token }:

  1. Find the row by sha256(token).
  2. Check expiry — if expired, audit signin.magic.expired, 410.
  3. Check IP /24 — if mismatch, audit signin.magic.ip_mismatch, 410. The token is consumed regardless — the original link no longer works either, by design.
  4. Set used_at, mint the session inside the same transaction, audit signin.magic.ok.
  5. If redirect_uri was bound and it passes the allow-list, return it in the response so your SDK can navigate the user there.

Hosted-login flow

On <slug>.tilldev.app the user clicks the magic-link button, types their email, gets the mail, clicks the link. The hosted-login page handles the consume side and redirects back to the app — your app never has to embed the magic-link UI itself.

SDK

From the React SDK, hosting your own flow is two hooks:

// Send page
const { send, sent, pending } = useMagicLink()
await send({ email, redirect_uri: '/' })

// Callback page — read ?t= from the URL on mount
const { consume } = useMagicLink()
const r = await consume(token)
if (r.ok) router.push(r.redirect ?? '/')

When magic links are wrong

  • High-value workflows (admin dashboards, treasury, anything regulated) should use passkeys orOAuth. Mailbox access shouldn't grant superuser.
  • Shared inboxes (support@, team@) — the link works for whoever gets it first. Use email + password with MFA.

Next: OAuth · Google + GitHub or OIDC SSO.