Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 30 Oct 2026. What's built · Install 0.1.0
jadpo
Reference: Identity

Identity

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 its origin:, since the link starts a browser session at that origin;
  • an email connection in application { }'s email:, which sends the email, unless magic_link: names its own send: 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.

example
config {    browser_origin: Url    mail_sender: Email    smtp_host: Hostname}
example
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    }}
example
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}
example
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:

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

example
// 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:

text
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).

text
POST /auth/magic-link{ "address": "ada@example.com", "return_to": "/notes", "locale": "fr_fr" }202 Accepted                      (no body; the same for every address)
  • address is required. return_to and locale are optional.
  • return_to is 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.example or https://..., is a 422 invalid_input. Without it the person goes to /.
  • The email's language is locale when it is a supported locale, else the best supported match for the request's Accept-Language, else your default locale.
  • The answer is 202 with 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 declare on_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:, and codes_per_hour: an hour. Over either limit the answer is the built-in rate_limited (429, with Retry-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.

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:

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

text
POST /auth/magic-link/confirm{ "address": "ada@example.com", "code": "042317" }

Your own client trades the code for an API token:

text
POST /auth/magic-link/redeem{ "address": "ada@example.com", "code": "042317" }200 OK{ "token": "jadpo_token_9c1e..." }
  • A confirm POST needs an Origin header naming your app's own site, as any write with the session cookie does.
  • After signing in, your frontend is given only the return_to path 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):

example
// 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 calls magic_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 its fails (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-in MagicLinkEmail. Jadpo still makes the link and the code and sends it.
  • send: names an action of yours that sends the email itself, given a MagicLinkRequest.

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.