Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 5 Nov 2026. What's built · Install 0.3.0
jadpo
ReferenceSign-in and identity

APIsSign-in and identity

Routes: signed in by default

On this page

Every route needs a signed-in caller unless it says otherwise. You don't write anything for that. A request without a valid session or token is refused with a 401 before your code runs.

A route changes this with an authentication: line, which takes one of two values.

Open to anyone: RouteAuthentication.none

Use it for routes people call before they have an account, such as a health check or a sign-up form.

example
GET /health/live {    authentication: RouteAuthentication.none    output: Health    run: live()}

The route doesn't look for a session or token, so it never knows who is calling. The compiler stops anything the route runs from reading the caller (type.auth_public_principal).

Only the sign-in check is skipped. Input is still validated and limits still count. Policy still decides whether the route's action may run for someone who isn't signed in. A route can be open to anyone and still be refused by policy.

Check the caller again: RouteAuthentication.fresh

Use it where acting for someone who was signed out a moment ago would do harm, such as disabling an account, changing permissions, billing or exports.

example
DELETE /users/{user_id} {    authentication: RouteAuthentication.fresh    run: disable_user(user_id, current_principal.user)}

fresh only changes anything if your app sets a revocation_delay. By default, Jadpo looks up the caller's record on every request, so a session you revoke stops working at once. With a delay, for example revocation_delay: 5m, sessions are trusted for up to five minutes without that lookup, so a disabled user may keep working until then (see The application and revocation). A fresh route always does the lookup, whatever the delay.

Any other value

Any other authentication: value is a compile error. The error offers both fixes rather than guessing, because one keeps the route protected and the other opens it to anyone.

Confirm one request with a passkey: confirm: Confirmation.passkey

Use it where a stolen session or token must not be enough, such as releasing a package, moving money or deleting an account. The signed-in person approves this one request with one of their own passkeys.

example
use payouts/entities { Payout }use payouts/actions { send_payout }routes Payouts {    prefix: /payouts    POST /{payout_id: Payout.id}/send {        confirm: Confirmation.passkey        status: SuccessStatus.no_content        run: send_payout(payout_id)    }}

A client confirms a request in three steps, around the browser's own prompt. It first sends the same request to the route's generated <path>/confirmation, with the same method, placeholders, query or body and headers (a GET's goes as a POST, since asking keeps a challenge), for the options to give navigator.credentials.get() (PublicKeyCredential.parseRequestOptionsFromJSON reads what publicKey holds):

text
POST /payouts/0192d3c4-.../send/confirmation200 OK{ "publicKey": { "challenge": "Vh3q...", "rpId": "payouts.example", "allowCredentials": [{ "type": "public-key", "id": "AQID..." }], "userVerification": "required", "timeout": 120000 } }

The browser has the person present a passkey, and the request itself carries the device's answer in X-Jadpo-Confirmation, the JSON WebAuthn's toJSON() writes, in base64 (btoa(JSON.stringify(credential))):

text
POST /payouts/0192d3c4-.../sendX-Jadpo-Confirmation: eyJpZCI6IkFRSUQi...204 No Content
  • The challenge is bound to the request. The runtime reads the options request as the route itself would, so input the route would refuse is refused here, before anyone is asked for a passkey. It binds the challenge to the signed-in person and a digest of the route and that input, the one an Idempotency-Key is checked against. So the same input written differently, with its members in another order, is the same request.
  • The options list only the person's passkeys. A person with none is refused with 403 confirmation_required rather than given options any passkey could answer, so a frontend sends them to add one.
  • It is checked before anything runs: the passkey must be one the signed-in person registered, the answer must show the person was verified (a fingerprint, a face or a PIN), and the challenge must be unused, under two minutes old and match this request's input. Anything else is refused with 403 confirmation_required, and nothing runs. The answer is checked as a passkey's sign-in is (Signing in): the origin, the relying party, the signature and the counter. On success the passkey's counter, backup state and last use are written.
  • Each challenge works once, so an answer can't be replayed, and one made for one request can't approve another. It is used up when the answer arrives, whether or not the answer checks. A new options request for the same person, route and input replaces the earlier one.
  • The two minutes are fixed. passkey:'s expires_after: doesn't change them.
  • A retry confirms again. An Idempotency-Key is checked after the confirmation, so a client repeating a request with its key presents a passkey again and gets the first response. A confirmation can't be skipped by repeating a key.
  • It needs a person signed in. confirm: on a route with authentication: RouteAuthentication.none, or one that passes on current_principal.service, is route.confirm_invalid. Any credential of a signed-in person can carry an answer, since the answer is what proves it's them; a route that also needs their record read now adds authentication: RouteAuthentication.fresh.
  • It needs passkey: in sign_in: (semantic.auth_setting_required), and a route that needs it says so in its API's record (confirm passkey) and OpenAPI (x-jadpo-confirm, with the header and the 403). The generated route is listed beside it in both and in jadpo routes, marked generated. A route your app writes at that path is route.path_overlap, as is a confirmed GET beside a confirmed POST, which would share one.
  • Taking it off needs a person. A route that stops asking for a confirmation needs an acceptance, PolicyChange.confirmation_removed (Records), since a stolen session is then enough again.
  • In route tests, test_passkeys.confirm(passkey) answers the next confirmation with that test passkey (Passkeys in tests).

A route test confirms the next request with a test passkey. Without it, or with another person's, nothing runs:

example
use payouts/entities { Payout }use payouts/fixtures { drafted_by, treasurer_called }test "a payout goes out once its treasurer confirms it with a passkey" {    var ada: Principal.user = treasurer_called("ada@example.com")    var phone: TestPasskey = test_passkeys.register(ada, "Phone")    var payout: Payout = drafted_by(ada)    test_passkeys.confirm(phone)    var response: Response = request {        method: HttpMethod.post        path: "/payouts/{payout_id}/send"        params: {            payout_id: payout.id        }        as: ada    }    assert response.status == 204}test "a stolen session alone sends nothing" {    var ada: Principal.user = treasurer_called("ada@example.com")    var phone: TestPasskey = test_passkeys.register(ada, "Phone")    var payout: Payout = drafted_by(ada)    var response: Response = request {        method: HttpMethod.post        path: "/payouts/{payout_id}/send"        params: {            payout_id: payout.id        }        as: ada    }    assert response.status == 403    assert response.code == "confirmation_required"    var signed_in: Response = test_passkeys.sign_in(phone)    assert signed_in.status == 204}test "another person's passkey confirms nothing" {    var ada: Principal.user = treasurer_called("ada@example.com")    var mallory: Principal.user = treasurer_called("mallory@example.com")    var ada_phone: TestPasskey = test_passkeys.register(ada, "Phone")    var mallory_phone: TestPasskey = test_passkeys.register(mallory, "Phone")    var payout: Payout = drafted_by(ada)    test_passkeys.confirm(mallory_phone)    var response: Response = request {        method: HttpMethod.post        path: "/payouts/{payout_id}/send"        params: {            payout_id: payout.id        }        as: ada    }    assert response.status == 403    assert response.code == "confirmation_required"    var signed_in: Response = test_passkeys.sign_in(ada_phone)    assert signed_in.status == 204}

The API record says the route asks for a passkey, and lists the route Jadpo answers beside it:

text
POST /payouts/{payout_id: Uuid}/send    confirm passkey    status 204    fails payout_not_foundPOST /payouts/{payout_id: Uuid}/send/confirmation    generated confirmation    caller user    output PasskeyRequestOptions    status 200

Apps with more than one API

An app can serve several APIs, for example main for its own clients and partners for other companies' servers, each under its own path prefix (APIs). Each API lists the ways a caller may prove who they are, and every route in it accepts those:

app.jadpo
// app.jadpoapis: {    main: {        prefix: /        authentication: [AuthenticationMethod.browser_session, AuthenticationMethod.api_token]    }    partners: {        prefix: /partners        authentication: [AuthenticationMethod.service_key]    }}

Here a browser session or an API token works on any route under /, and only a service key works under /partners. A route can narrow its API's list but never add to it. An API with no authentication: line accepts every method the application declares, and one that says authentication: [] accepts none: every route in it is open to anyone, and says so (Serving the document).

Webhooks from a provider such as Stripe aren't routes you write, so there's nothing here to set. Jadpo generates them, checks each one's signature and passes it to your app as an event (External services).

Markdown for agents: /docs/routes-signed-in-by-default.md.Built from Jadpo main at aee2e8f.