Embedded account settings.
GAReset 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.
Two lanes, one pipeline
| Lane | UI owner | Use it for |
|---|---|---|
| Hosted pages | TillAuth | Zero-code default. Also the mandatory landing for every email link (magic sign-in, verification, reset). |
| Embedded — the /v1 API | You | Your 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.
What makes your origin eligible
- Register your origin. Adding
https://yourapp.comto the app's redirect origins is the same act that admits it to CORS forauth.tilldev.dev. No separate API-access switch. - Hold the user's access token. Every sign-in completion mints the same realm-bound token — hosted sign-in, the raw
/v1/signinAPI, passkeys, magic links, and the OIDC code exchange alike. Whatever flow you used, the token you hold is the one this API accepts. - 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.
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.The read model: /v1/me
One call returns everything a settings screen renders — counts and booleans, never secrets or credential ids:
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()){
"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.
Password: set vs change
| Route | Proves | Afterwards |
|---|---|---|
POST /v1/password/set | The 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/change | The 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-request | Nothing (signed-out flow). Always answers ok — non-enumerating. | The emailed link lands on the hosted reset page, never on your origin. |
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.
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.
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:
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.
Email links always land hosted
You can embed every trigger — POST /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).
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:
curl https://auth.tilldev.dev/openapi.jsonNext: the API reference · or sessions for the sign-out and device-management half of a settings screen.