TILLAUTH · FLOWS
Magic links — bound, brief, single-use.
GAMagic links are the most-stolen credential on the internet because they live in inboxes. TillAuth's links are bound to the requesting network, expire in 15 minutes, and are single-use. Forwarding the email gets you nowhere.
01Anatomy
What's actually in the link
- Token: a high-entropy token; only its hash 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/consumehit, the token is marked used inside the same transaction that mints the session. Replay is a 400Link already used. - Network binding: the link is bound to the requesting network at send time; consume must come from the same network. A forwarded link that opens in a different network refuses to log in.
- Redirect allow-list: the optional
redirect_uriis checked against the app'sallowed_redirect_origins. Mismatch = the link refuses to consume.
02Request
Send
POST /v1/magic/send with { email, redirect_uri? }:
- Rate limit per IP and per (app, email) — no email-bombing surface.
- Mint the token, store only its hash bound to the requesting network and the
redirect_uri, with a 15-minute TTL. - Send the email via your configured provider (Resend by default), using the per-app branding template.
- Always answer success-shaped (
{ ok: true }) — never reveal whether the email matched a real user. Audit row:signin.magic.sent.
03Redeem
Consume
POST /v1/magic/consume with { token }:
- Find the token by its hash.
- Check expiry — if expired, audit
signin.magic.expired, 400Invalid or expired link. - Check the network — if it doesn't match, audit
signin.magic.ip_mismatch, 403 with a message telling the user to request a fresh link. The token is consumed regardless — the original link no longer works either, by design. - Mark the token used, mint the session inside the same transaction, audit
signin.magic.ok. - If the account has TOTP enrolled, the response is an MFA challenge (
{ mfa_required: true, challenge_token }) instead of a session — complete it at/v1/totp/verify-signin. - If
redirect_uriwas bound and it passes the allow-list, return it in the response so your SDK can navigate the user there.
Password-less is a start, not a sentence
An account born from a magic link has no password — and never needs one. But if the user wants one,
POST /v1/password/set adds it from their signed-in session, no mailbox round-trip. See embedded account settings.04Hosted
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.
05SDK
React SDK
From the React SDK, hosting your own flow is two hooks:
ts
// 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 ?? '/')06Fit
Next: OAuth · Google + GitHub or OIDC SSO.