TILLAUTH · BUILD

Embedded account settings.

GA

Reset password, manage passkeys, turn on two-factor — many apps want these in their own settings screen, not on a hosted page. That works here by design: the hosted pages are reference clients of the same /v1 API, and your registered origin can call it directly. Your UI, our invariants.

01Model

Two lanes, one pipeline

LaneUI ownerUse it for
Hosted pagesTillAuthZero-code default. Also the mandatory landing for every email link (magic sign-in, verification, reset).
Embedded — the /v1 APIYouYour settings screen: set/change password, passkeys, TOTP, sessions, factor inventory.

Every rule lives server-side: per-app method policy, step-up proof, rate limits, and the credential floor (no removal that would strand an account). Your UI cannot weaken them — worst case it renders a button the server refuses with a reason written to be shown to the user.

02Setup

What makes your origin eligible

  1. Register your origin. Adding https://yourapp.com to the app's redirect origins is the same act that admits it to CORS for auth.tilldev.dev. No separate API-access switch.
  2. Hold the user's access token. Every sign-in completion mints the same realm-bound token — hosted sign-in, the raw /v1/signin API, passkeys, magic links, and the OIDC code exchange alike. Whatever flow you used, the token you hold is the one this API accepts.
  3. Name the realm. Send X-TillAuth-App: <app public id> on every call. Account routes are realm-strict: a token from another app is a plain 401 here.
No cookies
The API is bearer-token only (credentials: false in CORS terms). There is no ambient session to CSRF — but treat the token like the credential it is: keep it out of URLs and logs.
03Read

The read model: /v1/me

One call returns everything a settings screen renders — counts and booleans, never secrets or credential ids:

ts
const me = await fetch('https://auth.tilldev.dev/v1/me', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${accessToken}`,
    'X-TillAuth-App': APP_ID,
  },
}).then((r) => r.json())
json
{
  "user": {
    "id": "…", "email": "ada@yourapp.com", "email_verified": true,
    "display_name": "Ada",
    "has_password": false,
    "mfa_enrolled": true,
    "factors": {
      "totp": true, "backup_codes": 8,
      "passkeys": 1, "passkeys_locked": 0,
      "push_devices": 0
    }
  }
}

has_password: false is common — accounts born from a magic link, OAuth, or an invite. Render “Set password” instead of “Change” and call the matching route below.

04Write

Password: set vs change

RouteProvesAfterwards
POST /v1/password/setThe session (plus step-up if a second factor exists). 409 if a password already exists.Sessions stay valid — the account gained a credential.
POST /v1/password/changeThe current password (current_password + new_password).Every session is revoked, including the caller's. Tell the user before they submit; re-authenticate after.
POST /v1/password/reset-requestNothing (signed-out flow). Always answers ok — non-enumerating.The emailed link lands on the hosted reset page, never on your origin.
05Write

Passkeys: list, add, remove, unlock

GET /v1/passkeys lists them (label, last used, and lock state). Registration is the standard WebAuthn dance — register/start returns creation options, the browser signs, and register/finish stores the credential. Full shapes in the API reference.

A passkey whose hardware counter went backwards is locked on sight (possible clone) rather than accepted. Your list will show locked_at/locked_reason — offer both honest exits:

  • POST /v1/passkeys/:id/unlock — the user restored or migrated their device. The counter re-syncs on next use.
  • DELETE /v1/passkeys/:id — they don't recognise the event. Locked passkeys are always deletable.
409 — the floor
Removing a live passkey that is the account's last way in — or its last second factor on an MFA-required app — is refused with 409 and a reason (e.g. “Set a password before removing it.”). Show the reason verbatim; it always names the fix.
06Write

TOTP and backup codes

POST /v1/totp/setup → QR + secret → user scans → POST /v1/totp/verify-setup with the first code. The verify response carries the 10 single-use backup codes (shown exactly once — render them for saving) and a fresh token pair with mfa: true — store it, replacing the old one. POST /v1/totp/disable turns it off.

The proof ladder adapts to what the account holds: accounts with a password confirm it (current_password); password-less accounts proceed on their session alone — or, once any second factor exists, must present an mfa: true session.

07Refusals

Handle step_up_required

Adding or removing factors on an account that already holds one demands a session that proved a factor. When yours hasn't:

json
HTTP 403
{
  "error": "step_up_required",
  "error_description": "Re-authenticate with your two-factor method before setting a password."
}

Recovery is one loop: send the user back through sign-in (hosted or your OIDC authorize URL). Completing their factor mints an mfa: true token — then retry the call. Show error_description while you do; it says exactly what the user must present.

08Boundary

Email links always land hosted

You can embed every triggerPOST /v1/magic/send, POST /v1/password/reset-request, POST /v1/verify-email/resend — but the emailed link itself always lands on the hosted pages: the token in that link must be verified on an origin TillAuth controls. After the click-through the user returns via the app's validated redirect (redirect_uri on magic send must be in your allow-list).

Non-enumeration
These triggers answer success-shaped whether or not the account exists. Don't build UI that waits for “no such account” — it will never come.
09Reference

The whole surface, machine-readable

Every route, shape, and refusal in this guide is specified in the OpenAPI document — browse the API reference or fetch it directly:

bash
curl https://auth.tilldev.dev/openapi.json

Next: the API reference · or sessions for the sign-out and device-management half of a settings screen.