Skip to content

Product documentation

Author a custom login flow

The declarative, versioned login-flow model — stages, authenticators, requirements, and declarative conditions — and how a tenant authors one in the console flow editor: save a validated draft, then activate it. Covers the customer-plane API, the validation contract, the behaviour-neutral default flow, and the staged rollout of the execution engine.

Author a custom login flow

A login flow is a declarative description of the journey a user walks to sign in to a Thoryn-hosted account: which authenticators the login page offers, in what order, and under what conditions. It is authored per tenant, versioned as a draft you validate and then activate, and stored by identity-service — the service that owns login and user data.

This governs identity-service's native login (password, magic-link, and the other first-party authenticators). It does not govern what an external IdP does once a user is handed to it — choosing a federated IdP is itself a single step in the flow, not the external provider's own login.

Read this for the status first. The authoring surface — the model, server-side validation, versioned draft/active storage, and activation — is live and is what this guide documents. The execution engine that runs an active flow on the live login page is rolled out in stages, fail-closed. It is live in enforce on staging (and off in production), but behaviour-neutral for the default flow — it changes what a user sees only where a tenant has authored a custom flow. See Rollout status before you rely on activation to alter the page.

The model

A flow is a small, declarative document — never tenant-authored code. It has three levels.

Flow. A version (a monotonic integer), a status (DRAFT or ACTIVE — at most one active per tenant), and an ordered list of stages. Order is significant: it is the order the journey walks.

Stage. One step in the journey:

  • id — a stable, non-blank identifier, unique within the flow.
  • authenticators — one or more typed authenticators the stage offers (a stage with more than one offers them as choices at that step). At least one is required.
  • requirement — how the stage participates in the walk: REQUIRED (must be satisfied), ALTERNATIVE (one of the sibling alternatives satisfies the step), CONDITIONAL (runs only when its gating condition is true), or DISABLED (retained but inert).
  • condition — an optional declarative predicate that gates the stage. Required when the requirement is CONDITIONAL.

Authenticator. A typed node — { "type": …, "config": { … } } — drawn from the platform's native capabilities: PASSWORD, MAGIC_LINK, PASSKEY, TOTP, SMS, EMAIL_VERIFY, FEDERATED_SSO, STEP_UP. config is a bag of declarative, string-valued parameters. Adding an authenticator is a new typed node in the platform, never tenant code.

The authenticator vocabulary above is the model's full set, but which types the engine actually renders is part of the staged rollout. Today's behaviour-neutral default flow uses only your enabled login methods (see below); PASSKEY / TOTP / STEP_UP and friends as orderable flow nodes are on the roadmap.

Step-up target assurance level

A STEP_UP node re-authenticates to a higher assurance level. Its config.acr names the target level on the canonical assurance ladder — one of urn:thoryn:acr:1fa, urn:thoryn:acr:mfa, or urn:thoryn:acr:phishing_resistant (the legacy urn:thoryn:loa:* spellings are accepted as aliases). The node is then satisfied by any enrolled method whose own assurance is at least that level (assurance-level primary): an mfa step-up is realised by a TOTP, an SMS, or a stronger passkey; a phishing_resistant step-up only by a passkey. The realising set is bounded by your tenant's enabled methods.

A STEP_UP node with no config.acr keeps the historical behaviour — realised by a TOTP or SMS. An acr value that is not a canonical ladder level is rejected on save (422 invalid_login_flow, error code MALFORMED_STEP_UP_ACR). The per-level realising-method sets are derived — computed from the canonical ladder intersected with your tenant's enabled methods, never stored.

Conditions

A condition is a declarative predicate tree — and / or / not composites over leaf predicates — that reads existing login-context signals only. A leaf names a signal type (RISK_LEVEL, KNOWN_DEVICE, ORG_MEMBERSHIP, REQUESTED_ACR, USER_ATTRIBUTE, CLIENT), an operator (EQUALS, NOT_EQUALS, IN, NOT_IN, GREATER_OR_EQUAL, LESS_OR_EQUAL), and its operand values. The tree serialises with an explicit op discriminator, for example:

{ "op": "leaf", "type": "RISK_LEVEL", "operator": "GREATER_OR_EQUAL", "values": ["high"] }

The condition model and its validation are shipped; evaluating conditions against a live sign-in is part of the engine rollout described below.

The default flow is behaviour-neutral

A tenant that has never authored a flow is not without one. identity-service derives a default flow from the tenant's ordered login-method policy: each enabled method becomes its own single-authenticator ALTERNATIVE stage, in the configured order. A user satisfies the flow by completing any one of them — which is exactly today's login behaviour.

So the default flow is a faithful representation of the login-method policy, not a change to it. Authoring a custom flow is opt-in; nothing about a tenant's sign-in changes until they save and activate one. Where the engine runs in enforce (staging today; off in production), an activated flow then drives the login page — see Rollout status.

Start from a template

You do not have to compose a flow from a blank canvas. identity-service ships a small catalog of starter templates — pre-built, validated flows you can list and apply as a draft, then edit and activate like any other. Templates are static and global (identical for every tenant, carrying no tenant data); applying one writes a new draft scoped to your tenant, which you activate through the normal lifecycle below.

Template idWhat it does
password-onlyA single email + password step — the simplest baseline.
mfa-alwaysEmail + password, then a required authenticator-app (TOTP) step on every sign-in.
passwordless-preferredPasskey and magic-link offered first, with password kept as a fallback choice.
risk-based-step-upEmail + password, then a step-up re-authentication only when the sign-in risk is high.

List the catalog, then apply one — the response is a saved draft, the same shape a PUT /api/v1/login-flows/draft returns, carrying the allocated version:

# List the starter templates (id, name, description, and the flow each starts from).
curl https://api.thoryn.org/api/v1/login-flows/templates \
  -H "Authorization: Bearer $TOKEN"
 
# Apply one — creates a new DRAFT for your tenant from the template.
curl -X POST https://api.thoryn.org/api/v1/login-flows/templates/mfa-always/apply \
  -H "Authorization: Bearer $TOKEN"

Applying a template does not activate it. Review or edit the draft it creates, then activate the version it came back with (see Authoring workflow below). An unknown template id is 404 login_flow_template_not_found. Each template uses only authenticators the model already defines; which of those the engine actually renders on the live page follows the staged rollout, exactly as for a hand-authored flow.

Authoring workflow

Console (recommended). The console's Customisation section hosts a login-flow editor. Compose the flow there, save it as a draft, and activate it when you are happy. The editor reads and writes the customer-plane API below.

The lifecycle is draft → validate → activate:

  1. Save a draft. PUT /api/v1/login-flows/draft with the flow definition. The server validates it (see Validation); on success it stores a new draft version and returns the saved definition, whose version you use to activate. Nothing is persisted if validation fails.
  2. Activate (publish). POST /api/v1/login-flows/activate with { "version": N }. That version becomes the tenant's single ACTIVE flow, and the previous active is retired to a retained prior version (RETIRED) — kept for history and rollback, never resurrected as your draft. Your work-in-progress draft, if any, is left untouched.

Endpoint reference

All calls go to product-api through the api-gateway with a tenant admin bearer token. The tenant is taken from your token's tnt claim — there is no tenant in the path, so you can only ever reach your own tenant's flow.

MethodPathDescription
GET/api/v1/login-flows/activeThe effective active flow (the stored active one, else the generated default). Always 200.
GET/api/v1/login-flows/draftThe current draft, or 404 login_flow_draft_not_found when there is none.
PUT/api/v1/login-flows/draftSave the flow as a new draft version. 422 invalid_login_flow or 400 malformed_login_flow on rejection; nothing is persisted.
POST/api/v1/login-flows/activateActivate (publish) a version, or roll back to a prior one. 404 login_flow_not_found when the version does not exist.
GET/api/v1/login-flows/versionsThe tenant's version history — { version, status, createdAt, updatedAt } (newest first; DRAFT/ACTIVE/RETIRED). Always 200.
GET/api/v1/login-flows/templatesList the starter-template catalog (static, global). Always 200.
POST/api/v1/login-flows/templates/{templateId}/applyCreate a draft from a starter template. 404 login_flow_template_not_found when the id is unknown.
POST/api/v1/login-flows/simulateDry-run a flow against a synthetic context (read-only). Reports what it would render. 422 invalid_login_flow on an invalid inline flow.
GET/api/v1/login-flows/client-bindingsList all per-application flow bindings for the tenant ({ clientId, version } array). Always 200.
GET/api/v1/login-flows/client-bindings/{clientId}The binding for one OAuth client, or 404 login_flow_client_binding_not_found when it is unbound.
PUT/api/v1/login-flows/client-bindings/{clientId}Bind an OAuth client to a flow version ({ "version": N }). 404 login_flow_not_found when that version does not exist.
DELETE/api/v1/login-flows/client-bindings/{clientId}Remove a client's binding (it reverts to the tenant default). 204, or 404 when already unbound.

Scopes. Composing the login page is a login-experience concern, so the flow endpoints ride the same scope family as login branding and the login-method policy: tenant:idp.read for the reads, tenant:idp.write for the writes. There is no separate scope to grant.

Save a minimal single-stage draft, then activate the version it returns:

# Save a draft — the response carries the allocated "version".
curl -X PUT https://api.thoryn.org/api/v1/login-flows/draft \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "version": 1,
    "status": "DRAFT",
    "stages": [
      { "id": "password", "requirement": "ALTERNATIVE",
        "authenticators": [ { "type": "PASSWORD" } ] }
    ]
  }'
 
# Activate the version the draft came back with.
curl -X POST https://api.thoryn.org/api/v1/login-flows/activate \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "version": 4 }'

The full request/response schemas are in the generated LoginFlow API reference.

Validation

A draft is validated server-side before it is stored, and an activation re-validates its target — identity-service is the sole authority for the schema. A structurally invalid flow is rejected with 422 invalid_login_flow, carrying a structured errors array (each with a code, a stable message, and the offending stageId) so an editor can surface every problem at once. An unparseable body is 400 malformed_login_flow. The structural rules:

  • the flow has at least one stage;
  • every stage id is non-blank and unique within the flow;
  • every stage offers at least one authenticator;
  • at least one stage is not DISABLED (an all-disabled flow would run nothing);
  • a CONDITIONAL stage carries a gating condition;
  • every condition tree is well-formed — non-empty composites; leaves with the right operand count for their operator; a RISK_LEVEL operand that names a real risk level; a KNOWN_DEVICE operand that is true/false; and the ordered operators (GREATER_OR_EQUAL / LESS_OR_EQUAL) used only on the ordinal RISK_LEVEL signal.

Versioning and publishing

Every save is a new immutable version. A tenant accumulates versions as you edit, and exactly one is ACTIVE at a time (the flow the engine walks). Each version carries a lifecycle status:

StatusMeaning
DRAFTEditable, not served to the engine. Saving a draft creates the next version.
ACTIVEThe single live flow the login page runs. At most one per tenant.
RETIREDA version that was active and has since been superseded by a later publish. Retained for history and rollback; never served, never resurrected as your draft.

Publishing. Activating a version makes it the single ACTIVE flow and retires the previous active to RETIRED. The invariant at most one ACTIVE version per tenant holds across the operation (it is enforced in the database, not just the service).

History. GET /api/v1/login-flows/versions returns every version, newest first, so the console can show the full history:

curl https://api.thoryn.org/api/v1/login-flows/versions \
  -H "Authorization: Bearer $TOKEN"
# [
#   { "version": 3, "status": "ACTIVE",  "createdAt": "…", "updatedAt": "…" },
#   { "version": 2, "status": "RETIRED", "createdAt": "…", "updatedAt": "…" },
#   { "version": 1, "status": "RETIRED", "createdAt": "…", "updatedAt": "…" }
# ]

The response lists version numbers, statuses, and timestamps — not the full definitions. Fetch the live one with GET /active, or re-activate a listed version to make it live.

Rollback. Rolling back is just activation of an older version — there is no separate endpoint. POST /api/v1/login-flows/activate with a RETIRED version number re-validates it, makes it ACTIVE again, and retires the version that was active:

# Roll back to version 2 (picked from the history above).
curl -X POST https://api.thoryn.org/api/v1/login-flows/activate \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "version": 2 }'

Because activation re-validates its target, a version can only ever be (re-)published while it is still structurally valid.

Every publish and rollback is audited

The login flow decides how your users authenticate, so each lifecycle change is written to your tenant's audit trail — the same verifiable, tamper-evident chain that records OAuth client and federation-member changes:

  • Publishing a draft records a login-flow.published event.
  • Rolling back to a prior version records a login-flow.rolled-back event.

Each event captures who made the change (the acting admin), when, the version that became active, and the version it superseded — enough to answer "who changed the login flow, and from which version to which" for compliance and forensics. The events carry no flow contents or secrets. Read them through the audit-events surface, filtered by action. Saving a draft is not audited: a draft is inert — it changes nothing about how any user signs in until you activate it.

Test a flow (simulate)

Before you activate a flow — or even before you save it — you can dry-run it against a hypothetical user and see exactly what it would render. POST /api/v1/login-flows/simulate is read-only: it persists nothing (no draft, no journey) and touches no user's login. It runs the same execution engine the live walk uses, so what it reports is what enforcement would do for the same inputs.

Pick which flow to simulate with flow.sourceactive (default), draft, or inline (a definition you pass in flow.definition, validated first exactly as a draft save is) — and describe the hypothetical user in context. Every context signal is optional; an omitted one is absent, which the engine treats as indeterminate and resolves toward more authentication (it includes a gated step rather than dropping it) — the same fail-closed posture as a real pre-auth render.

# Dry-run an inline flow for a high-risk sign-in — does the step-up fire?
curl -X POST https://api.thoryn.org/api/v1/login-flows/simulate \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "context": { "riskLevel": "high", "knownDevice": false },
    "flow": {
      "source": "inline",
      "definition": {
        "version": 1, "status": "DRAFT",
        "stages": [
          { "id": "password", "requirement": "REQUIRED",
            "authenticators": [ { "type": "PASSWORD" } ] },
          { "id": "step_up", "requirement": "CONDITIONAL",
            "authenticators": [ { "type": "STEP_UP" } ],
            "condition": { "op": "leaf", "type": "RISK_LEVEL",
                           "operator": "GREATER_OR_EQUAL", "values": ["high"] } }
        ]
      }
    }
  }'

The response reports:

  • firstStep — the authenticators the login page would present at the first step.
  • steps — the whole walk, one step at a time (e.g. password then step-up), so you can see a multi-step journey unfold.
  • outcomeCOMPLETE, or FAIL_CLOSED when the flow is degenerate and would fall back to the safe default.
  • stageEvaluations — per stage, whether it was included for this context and, for a gated stage, its conditionOutcome (MATCH / NO_MATCH / INDETERMINATE). This is the direct answer to "why did this step show (or not)?": the RISK_LEVEL >= high gate above reads MATCH for riskLevel: "high" and includes the step-up; drop the risk to none and it reads NO_MATCH and the step-up disappears; omit riskLevel entirely and it reads INDETERMINATE and the step-up is included fail-closed.

Simulate rides the read scope, tenant:idp.read — a dry run is a read of "what would this flow do". An invalid inline definition is rejected with 422 invalid_login_flow and the same structured errors array a draft save returns, so you can fix it before you ever store it. Because simulate runs the real engine, it reflects the rollout status: it faithfully shows what the engine would render, which becomes what users see once the enforce slice lands.

Per-application flows

By default every relying-party app under a tenant runs that tenant's single active flow. You can instead bind a specific OAuth client to a specific flow version, so different apps get different login experiences — e.g. a customer-facing app on passwordless-first while an internal admin app requires MFA.

A binding maps clientId → flow version. Author the flow as a normal draft first (it does not need to be the tenant's active flow — any existing version can be bound), then bind a client to that version:

# 1. Save the app-specific flow as a draft — note the "version" it returns.
curl -X PUT https://api.thoryn.org/api/v1/login-flows/draft \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "version": 1, "status": "DRAFT", "stages": [
        { "id": "passkey", "requirement": "ALTERNATIVE",
          "authenticators": [ { "type": "PASSKEY" } ] } ] }'
 
# 2. Bind the OAuth client to that version.
curl -X PUT https://api.thoryn.org/api/v1/login-flows/client-bindings/my-app-client-id \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "version": 7 }'

Resolution at login time is client-aware and fail-closed: when the in-flight OAuth client has a binding that resolves to a usable flow, that flow drives the login; otherwise — no binding, an unknown client, or a bound flow that is missing/corrupt/invalid — the login falls back to the tenant's active-or-default flow, exactly as an unbound tenant behaves today. A tenant with no bindings is byte-for-byte unchanged.

  • One binding per client. Binding a client that is already bound replaces its version.
  • DELETE reverts a client to the tenant default.
  • Tenant-scoped. A binding can only ever reference your own tenant's flow — a clientId from another tenant can never resolve your flow, and vice-versa.
  • Same scopes. Reads ride tenant:idp.read, writes tenant:idp.write — no new scope.

Client-aware resolution replaces the tenant-only lookup everywhere the engine reads the active flow (the render-order planner and the multi-step walk driver). As with the tenant flow itself, whether a resolved flow actually changes what a user sees is governed by the engine's rollout mode — see Rollout status below.

Rollout status

The login-flow execution engine — the code that loads a tenant's active flow and drives the live login page from it — ships as a staged, fail-closed rollout on identity-service's authentication hot path, behind one operator switch, oauthy.identity.login-flow.mode:

  • off — the engine is not invoked; the login page renders exactly as the login-method policy renders it. This is the default and today's production setting.
  • detectshadow: the engine computes its render plan and meters whether it matches the page's current rendering. It observes only — the page is byte-for-byte unchanged. This was the staging parity gate.
  • enforceshipped and live on staging. The engine drives the login render and the multi-step walk from the active flow. It is behaviour-neutral for the default flow — because the default flow reproduces the login-method policy exactly, an unchanged tenant sees precisely today's page — and fails closed: a missing, corrupt, or invalid active flow falls back to the generated default, then to a safe built-in floor, never to "no authentication".

A custom flow takes effect only where a tenant authors one and the environment runs enforce. Production/default stays off; the staging cut-over to enforce (2026-08-06) is the reference. See ADRs 2026-08-05-login-flow-model-and-execution-engine.md and 2026-08-06-login-flow-multistep-branching-execution.md.

Roadmap

Almost every capability this guide describes is shipped: the enforce engine, multi-step journeys and conditional branching, step-up / passkey / SMS as first-class flow nodes, truthful acr/amr from the factors actually completed, per-application flows, versioning with publish / rollback, starter templates, simulate-a-user, and the visual node/edge editor.

Passwordless-primary passkey entry has shipped (enforce-gated like the rest of the engine — live on staging, off in production): a flow whose first unit is a PASSKEY renders a usernameless passkey affordance on the login page, and a lone passkey completes a single-factor flow with truthful acr = urn:thoryn:acr:1fa, amr = swk (never mfa/hwk). The password form is retained as the fail-closed floor unless a tenant removes it from the login-method policy.

Chaining an additional factor after a passwordless passkey has shipped too: a passkey-first flow that leads with the passkey and then steps up (e.g. passkey → TOTP, passkey → conditional MFA, passkey → step-up) now runs end-to-end. The passwordless passkey verifies, the fresh principal is parked, the next factor's challenge is driven, and — once it verifies — the login carries truthful acr = urn:thoryn:acr:mfa, amr = [swk, otp, mfa] (the passkey plus the second factor). Fail-closed: the entry is offered only when the next node is a factor the platform can realise (TOTP, SMS, a step-up, or a passkey step-up); a next node it cannot realise (a federated handoff, an email-verify step) falls back to the password floor rather than completing a single factor. See ADR 2026-08-07-passwordless-primary-passkey-entry.md.

Security notes

  • Declarative-only, by design. A flow is data, never code. No tenant-authored code runs on the authentication path. Scriptable extensibility exists separately, at token/claim time, as Actions and Hooks — not as a login authenticator.
  • Fail-closed. The loader that the engine consumes never returns "no flow": a missing, unparseable, or structurally invalid active flow degrades to the generated default and then to a built-in floor. A store outage cannot lock every user out of the login page.
  • The hub auth-policy stays a floor. A custom flow may add friction (require MFA, add a step) but cannot go below a platform- or hub-required control. The floor is not something a flow can remove.
  • Tenant isolation is structural. The tenant rides in the internal path segment from your validated tnt claim, never a value you supply, so a caller can only ever read or change their own tenant's flow; a cross-tenant reference is a 404, never a 403.
  • Publish and rollback are auditable. Each activation writes a login-flow.published or login-flow.rolled-back row — actor, tenant, from/to version — to your tenant's verifiable audit chain. See Every publish and rollback is audited.