Magic links
On this page
A magic link signs a person in by email, with no password. They type their address, and Jadpo sends one email holding a link and a six-digit code. Either signs them in, once:
- the link opens a page of your frontend, which posts the link's token to Jadpo and gets a browser session;
- the code is typed into your own sign-in page, which posts it the same way, or your own mobile or command-line client trades it for an API token.
Jadpo generates the endpoints, never the pages: your frontend owns every page.
Magic links need three things besides magic_link: itself:
browser_session:with itsorigin:, since the link starts a browser session at that origin;- an email connection in
application { }'semail:, which sends the email, unlessmagic_link:names its ownsend:action (Your own email); application { display_name: }, your app's name, which the email uses.
link: is the one setting you must write: the path of the frontend page
the email's link opens, such as "/sign-in/confirm".
A whole app with magic links: the settings, the email connection and the app's name, the member, and the action that signs a new member up the first time they sign in.
config { browser_origin: Url mail_sender: Email smtp_host: Hostname}
use accounts/entities { Member }application { display_name: "Notes" connections: { mail: { driver: Driver.smtp host: config.smtp_host } } email: { connection: mail from: config.mail_sender }}principal { user: { subject: Member.email }}
use accountsauthentication { browser_session: { cookie: "notes_session" origin: config.browser_origin } api_token: {} sign_in: { magic_link: { link: "/sign-in/confirm" same_browser: true } } on_first_sign_in: accounts.create_member}
entity Member { id: Uuid email: Email { unique: true } policy: { create: [Access.system] read: [id] }}
on_first_sign_in: names create_member, which runs for an address no
member has (A person the app has not seen).
It runs as the system, the identity Jadpo uses for work no person asked
for (Access.system grants it), in its own transaction, and reaches no
tenant's records:
use accounts/entities { Member }use accounts/failures { SignUpClosed }/// Signs a new member up the first time they sign in with a proved address.action create_member(first: FirstSignIn) fails SignUpClosed -> Member.id { if (not first.address_verified) { reject SignUpClosed {} } match first.address { none -> { reject SignUpClosed {} } some(address) -> { var member: Member = attempt Member.create { set: { email: address } conflict: { Member.email: SignUpClosed } } return member.id } }}
The API record lists the routes Jadpo answers, each with a fixed body and
answer. The route that trades a code for a token comes with api_token::
// Generated by Jadpo from the routes of API main; update it with `jadpo api update`, never by hand.record api 1api main authentication api_token authentication browser_sessionPOST /auth/magic-link generated magic link authentication none input MagicLinkAsk (body) status 202POST /auth/magic-link/confirm generated magic link authentication none input MagicLinkConfirmation (body) output MagicLinkConfirmed status 200POST /auth/magic-link/redeem generated magic link authentication none input MagicLinkCode (body) output MagicLinkToken status 200GET /openapi.json generated openapi status 200output MagicLinkConfirmed return_to: Textoutput MagicLinkToken token: ApiToken
The routes
Jadpo generates three routes under path:, /auth/magic-link unless you
change it. Anyone may call them:
POST /auth/magic-link ask for a link and a codePOST /auth/magic-link/confirm a link's token or a code: starts a browser sessionPOST /auth/magic-link/redeem a code for an API token (needs api_token:)
Each route's response shape is fixed (Generated routes). A failure comes back in the usual failure body, and your frontend shows its wording, which the per-locale failure export carries (Configuration and time).
Asking for a link
POST /auth/magic-link{ "address": "ada@example.com", "return_to": "/notes", "locale": "fr_fr" }202 Accepted (no body; the same for every address)
addressis required.return_toandlocaleare optional.return_tois where the person goes once signed in. It must be a path on your app's own site: it starts with one/, and anything else, such as//evil.exampleorhttps://..., is a 422invalid_input. Without it the person goes to/.- The email's language is
localewhen it is a supported locale, else the best supported match for the request'sAccept-Language, else your default locale. - The answer is
202with no body whether or not an account has the address, so the route never tells anyone which addresses have an account. The email is sent after the request commits when an account has the address, or when you declareon_first_sign_in:. Otherwise nothing is sent. - Sends to one address are limited, counted the same whether or not an
account has it: one every
resend_after:, andcodes_per_hour:an hour. Over either limit the answer is the built-inrate_limited(429, withRetry-After), the same for unknown addresses. - Each send makes the earlier link and code for that address stop working. Only the latest is ever valid.
Confirming a link or a code
The link is <origin><link>?token=<64 hexadecimal digits>: the
origin: of browser_session: and the path link: names, for example
https://notes.example/sign-in/confirm?token=3f9a...c1. Your page reads
the token and posts it. Loading the page uses up nothing, so a mail
scanner that opens the link signs no one in. Only the POST does:
POST /auth/magic-link/confirm{ "token": "3f9a...c1" }200 OK{ "return_to": "/notes" } (the request's return_to)Set-Cookie: notes_session=...; notes_session_csrf=...
Your own sign-in page posts the code instead, and gets the same answer:
POST /auth/magic-link/confirm{ "address": "ada@example.com", "code": "042317" }
Your own client trades the code for an API token:
POST /auth/magic-link/redeem{ "address": "ada@example.com", "code": "042317" }200 OK{ "token": "jadpo_token_9c1e..." }
- A confirm
POSTneeds anOriginheader naming your app's own site, as any write with the session cookie does. - After signing in, your frontend is given only the
return_topath stored when the person asked, never an address the link carries. - A person signed in by link or code has proved they control the address,
so it reaches
on_first_sign_in:as verified.
Settings
Every setting but link: has a default. Tightening one is always
allowed. Loosening one past its limit will need an acceptance: a recorded,
reviewed sign-off on a change that weakens security
(Acceptances), which shows in
the approval report.
| Setting | Default | Needs an acceptance above |
|---|---|---|
address: |
the email field of the principal's user entity |
|
expires_after: |
15m, how long a link and a code last |
1h |
code_attempts: |
5 wrong guesses end a code |
10 |
codes_per_hour: |
10 sends per address |
|
failed_codes_per_day: |
20 wrong guesses per address |
50 |
resend_after: |
1m between sends to one address |
|
same_browser: |
false |
|
link: |
none: required, the frontend path the email's link opens, such as "/sign-in/confirm" |
|
path: |
"/auth/magic-link", where the generated routes go |
|
generate_routes: |
true; false generates none |
|
email: |
Jadpo's own email | |
send: |
none |
address: names a field of the principal's user entity that is built on
Email and is unique: true. Leave it out and the entity's email field
is used. With no such field, address: is required.
Wrong codes
code_attempts: wrong guesses end that code. Every wrong guess gets the
same answer, sign_in_code_invalid, never a count of guesses left. So
does a code that was used, ended, expired or replaced, and an address with
no code.
When an address reaches failed_codes_per_day: wrong guesses in a day
(UTC), codes stop working for it until the next day
(sign_in_codes_disabled), while links still work. Nothing locks an
account, so an attacker can't lock a person out.
Only in the browser that asked: same_browser:
same_browser: true makes a link work only in the browser that asked for
it. POST /auth/magic-link sets a <cookie>_sign_in cookie (HttpOnly,
Secure, SameSite=Lax, lasting expires_after:), and the confirm
refuses a link from any other browser (sign_in_link_other_browser). The
code still works anywhere.
What is kept
Link tokens are 32 random bytes. Codes are six random digits, kept as
text so a leading 0 stays. Jadpo keeps both only as verifiers, with a
digest of the address rather than the address itself, in the
jadpo_sign_in_requests system table. That table is in the store of the
principal's user entity, beside jadpo_sessions
(System tables). Each link and code works
once, and using either ends the other.
Writing the routes yourself
Use it when your sign-in needs a step of its own around the generated
behaviour. path: only moves the generated routes. generate_routes: false generates none, and your app writes its own routes over the
operations below. They keep every protection above: the limits, only the
latest being valid, the wrong-code rules, same_browser: and
on_first_sign_in:.
| Operation | Does |
|---|---|
magic_links.request(address: Email, locale: Locale, return_to: Text?) |
Sends the link and code after commit, as POST /auth/magic-link does; fails with rate_limited, and with sign_in_return_invalid (invalid_value, 422) for a return_to that is not a path on the app's site |
magic_links.redeem(token: SignInToken) |
Uses a link's token; gives a MagicLinkRedeemed (user_id, return_to: Text) or fails with a sign_in_ failure |
magic_links.redeem_code(address: Email, code: SignInCode) |
The same for a code |
Your action then calls sessions.start(redeemed.user_id) or
sessions.issue_token(redeemed.user_id):
// domains/accounts/actions/confirm_sign_in.jadpoaction confirm_sign_in(input: ConfirmSignIn) -> SignedInView { var redeemed: MagicLinkRedeemed = magic_links.redeem(input.token) sessions.start(redeemed.user_id) ...}
- Only actions call these operations, as with
sessions.start. same_browser:'s cookie is set on the response of the route whose action callsmagic_links.request.- The
sign_in_failures are built in and raised by Jadpo. Like every sign-in failure, they pass through an action without being listed in itsfails(Failures).
Your own email
Jadpo sends its own magic-link email, worded for you, through your
email: connection. Two settings change that
(Built-in emails):
email:names an email type of your own, built from the fields of the built-inMagicLinkEmail. Jadpo still makes the link and the code and sends it.send:names an action of yours that sends the email itself, given aMagicLinkRequest.
You can write one of them, not both.
Not built yet: a setting loosened past its limit is
toolchain.not_built_yet until acceptances are built. Keep each setting
within its limit for now.
Markdown for agents: /docs/magic-links.md.Built from Jadpo main at a707efb.