> ## Documentation Index
> Fetch the complete documentation index at: https://docs.corbado.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Model Your Authentication Journeys

> How to map your login, sign-up, recovery and enrollment journeys onto Corbado Observe flows, decisions and subflows before you write tracking code.

Custom events are only the transport. A backend classifier reads each session's event stream and produces the **flows**, **decisions** and **subflow attempts** that every dashboard is built on. Raw events remain visible in the console's [user search](https://app.corbado.com/observe/debugging/user-search) for debugging a single user, but the classified output is what carries the value.

The classifier is not visible from your integration, so this page describes the rules it applies and a method for mapping your journeys onto them. Reason about every event you emit from the classifier's perspective, not from how the raw series looks.

## 1. Three passes

Work the mapping in three passes, global concerns first. Mistakes in early passes cost the most. Later passes are local problems with a small blast radius that you can solve pragmatically.

<Steps>
  <Step title="Flow boundaries">
    For every flow, find the single best signal for when it starts, when it finishes successfully and, where your product has one, when it is skipped. Do this for all flows, top-level and nested, before anything else. The result of this pass is an exact definition of what is tracked. Precision matters most here: a `flowFinished()` without a matching open flow invalidates the whole session's classification.
  </Step>

  <Step title="Decision structure">
    Assign every screen of the journey to a decision name, then find the simplest way to determine the selectable option set per screen. Look for one mechanism that yields options globally if your app offers it, for example a server response that already lists the rendered choices.
  </Step>

  <Step title="Subflows">
    Fill in one auth method attempt at a time. What matters is creating the operation helper at the right moment, then mapping your app's signals onto the helper's steps.
  </Step>
</Steps>

The taxonomy is not a to-do list. Not every detail must be modeled. Prefer a clean, documented lossy mapping over a contorted complete one.

## 2. Flow boundaries

**One declared opener per flow.** `flowStarted()` fires at the flow's own entry screens only. Every other handler drops its signals when no flow is open instead of opening one lazily. Never re-announce a parent flow from a nested page "to make sure it is open": the classifier reads a repeated start of an outer flow as a restart and closes everything nested under it as incomplete.

**Finish only on success or explicit skip.** Do not model non-completion on the client. Incompleteness is classified from the absence of a `flowFinished()`. Skipping is the one exception, because it is semantically different from abandoning: send `flowFinished({ flowName, explicitOutcome: "skipped" })` for "continue as guest" or "not now", carrying the user reference whenever identity is already known.

**Nesting versus chaining.** Only `login` and `signup` can contain nested flows. A flow that itself establishes the session, such as a sign-up or a recovery started from the login page, nests inside `login`. When the nested flow's own terminal fires, complete the parent with `flowAutoFinished()`. A flow that runs after the session already exists, typically a passkey enrollment prompted after login, is chained: a sibling flow started after the login finished, never nested.

Events attribute to the innermost open flow. When the user leaves a nested flow without finishing it, close it explicitly with `explicitOutcome: "skipped"`, for example when entering sign-up abandons an open recovery. That records the right outcome and keeps the parent's subsequent events out of the nested flow.

**Ambiguous entry.** On a combined login and sign-up form, start with `flowNames: ["login", "signup"]` and resolve with `flowDecided()` once your backend knows whether the identifier exists.

See [Flows](/corbado-observe/tracking/flows) for the event reference.

## 3. Decision structure

A decision name is a **checkpoint** of the journey: a semantic unit that takes a successful auth method to pass and navigation to leave. Several screens usually map to one name (progressive disclosure, explanatory screens, switching between verification methods). Typical names are `pre-identifier` for everything before the identifier is submitted, `post-identifier` for the methods shown after it and `2fa`.

A screen's option set mixes two kinds of options:

| Kind | Examples | How it resolves |
| - | - | - |
| **Method options** | password, passkey, email OTP, social | The subflow that follows resolves the decision in classification. Never finish these explicitly. |
| **Navigational options** | back, change identifier, switch to sign-up, other methods, forgot password | Finish explicitly with `explicitDecisionValue` the moment the choice is made, then start the next screen's decision. |

Realistic logins have many navigational options. They are the adaptive, per-application part of the model, not an edge case. Name them for reuse across decisions.

A screen can lead into the **same subflow through several controls**: two buttons that run the same ceremony, a primary tile plus an "other methods" list, a prefilled versus a typed identifier. That is one option and one subflow, because only one option string can resolve. Express the difference through an explicit spec type where the taxonomy has one, otherwise carry it as a tag or accept the loss and document it.

See [Decisions](/corbado-observe/tracking/decisions) for presentation rules and the option strings each subflow resolves.

## 4. Subflows

A subflow is one auth method attempt or named process form. Creating its operation helper emits the attempt's start, so **when** you create the helper is the modeling question:

* **Input-bound methods** (password, OTP, identifier, provide-data) start when the input renders. The field itself is the attempt surface, and the helper captures interaction on it.
* **Action-bound methods** (passkey button, social button, app confirmation) start on the action, not when the button becomes visible. A helper created for a button nobody pressed produces an attempt without interaction: not counted for most types, counted as an incomplete attempt for passkeys.

For a coherent process form, create `formOperationFull({ name })` when it is shown. Report `submit` when the application accepts it or `skip` when the user proceeds without submitting. See [named process forms](/corbado-observe/tracking/subflows#3-16-named-process-form).

Keep every subflow and decision inside an open flow. Events sent before its start or after its finish do not produce stored subflows or decisions for search and exports. Fix the lifecycle signal rather than opening a flow lazily.

Never finish a subflow. The classifier derives each attempt's outcome from its steps, and the outcome-bearing step is `postResponse` for almost every subflow. Track the outcome-bearing step for the operation; named forms use `submit` or `skip`. For passkeys, also track the `ceremony` step: it powers all passkey analytics and nothing in it is recoverable from the server response alone.

Instrumentation is additive by default. It observes your app's existing lifecycle and adds no cancellation, timers or navigation rules of its own.

See [Subflows](/corbado-observe/tracking/subflows) for the helper reference.

## 5. Identity and tags

Call `setUser({ userId })` as soon as your authentication system supplies the user reference and while the flow is open, at the latest before `flowFinished()`. The SDK records a `flow_enriched` event on the open flow. Capture submitted identifiers separately on the provide-identifier step. An account resolved before authentication identifies the account targeted; it does not prove who is making the attempt. See [Users and identifiers](/corbado-observe/overview/users-and-identifiers). A skipped enrollment carries identity too, because the user is already known when the flow starts.

Tags ride flows. Configuration known at entry (product, variant, device class) goes on `flowStarted()`. Values only known on success go on `flowFinished()`. Do not re-fire the opener from reactive configuration such as store hydration or feature flags just to refresh its tags.

## 6. Journey catalog

The event series a correct integration produces for common journeys. Options are abbreviated and `spec` stands for `explicitSpecType`.

### 6.1 Identifier-first login, back, then sign-up

```text theme={null}
flow_started                  { flowName: login, touchpoint: account }
auth_method_decision_started  { pre-identifier, options: [identifier-email, switch-to-signup, social-google] }
subflow_started               { provide-identifier, spec: email }          identifier field rendered
subflow_step_started          { provide-identifier, pi-post-response }     identifier submitted
subflow_step_finished         { provide-identifier, pi-post-response }     resolves pre-identifier
auth_method_decision_started  { post-identifier, options: [password-login-known-identifier,
                                passkey-login-known-identifier, back] }
subflow_started               { password-login, spec: password-known-identifier }   password field rendered
auth_method_decision_finished { post-identifier, explicitDecisionValue: back }
auth_method_decision_started  { pre-identifier, options: [...] }           same checkpoint, re-presented
auth_method_decision_finished { pre-identifier, explicitDecisionValue: switch-to-signup }
flow_started                  { flowName: signup }                         nested in login
auth_method_decision_started  { signup-registration, options: [password-enrollment, back] }
subflow_started               { password-enrollment, spec: password-set }
subflow_step_started          { password-enrollment, post-response }
subflow_step_finished         { password-enrollment, post-response }       resolves signup-registration
flow_enriched                 { userId, identifier }                    setUser()
flow_finished                 { flowName: signup }
flow_auto_finished            { flowName: login, finishedByFlowName: signup }
```

Note what is absent: no decision `finished` for the method choices, no subflow finishes and no explicit abandon events. Had the user left mid-journey, the missing finishes would have classified it. The second `pre-identifier` decision is deliberately a second occurrence: the user genuinely revisited that checkpoint. The password attempt that rendered but was never submitted has no step and is not counted; the `back` finish resolves the `post-identifier` occurrence it was rendered in. The step names differ per helper (`pi-post-response` for the identifier, `post-response` elsewhere); the helpers emit them, you never type them.

### 6.2 Direct sign-up, then a skipped enrollment prompt

```text theme={null}
flow_started                  { flowName: signup, touchpoint: account }
auth_method_decision_started  { signup-registration, options: [password-enrollment] }
subflow_started               { password-enrollment, spec: password-set }
subflow_step_started          { password-enrollment, post-response }
subflow_step_finished         { password-enrollment, post-response }
flow_enriched                 { userId, identifier }                    setUser()
flow_finished                 { flowName: signup }
flow_started                  { flowName: enrollment, touchpoint: post-signup }   chained, not nested
auth_method_decision_started  { enrollment-passkey, options: [passkey-enrollment, skip] }
auth_method_decision_finished { enrollment-passkey, explicitDecisionValue: skip }
flow_finished                 { flowName: enrollment, explicitOutcome: skipped }
```

The sign-up establishes the session on its own. There is no `login` flow to auto-finish here; `flowAutoFinished()` belongs only to a parent that was actually started (6.1, 6.4). The enrollment starts after the sign-up finished, so it is a sibling.

### 6.3 Combined form resolved to login

```text theme={null}
flow_started                  { flowNames: [login, signup], defaultFlowName: login, touchpoint: checkout }
auth_method_decision_started  { pre-identifier, options: [identifier-email, social-google] }
subflow_started               { provide-identifier, spec: email }
subflow_step_started          { provide-identifier, pi-post-response }
subflow_step_finished         { provide-identifier, pi-post-response }
flow_enriched                 { userId }                                setUser()
flow_decided                  { flowName: login }                          identifier exists
auth_method_decision_started  { post-identifier, options: [passkey-login-known-identifier, other-methods] }
subflow_started               { passkey-login, spec: passkey-known-identifier }   button clicked
subflow_step_started/finished { passkey-login, get-options }
subflow_step_started/finished { passkey-login, ceremony }
subflow_step_started/finished { passkey-login, post-response }
flow_finished                 { flowName: login, userId, identifier }
```

### 6.4 Recovery nested in login with automatic login

```text theme={null}
flow_started                  { flowName: login, touchpoint: account }
...                                                                        identifier and password screens
auth_method_decision_finished { post-identifier, explicitDecisionValue: forgot-password }
flow_started                  { flowName: recovery, touchpoint: login }    nested in login
subflow_started               { email-link, spec: email-link-login }
subflow_step_started/finished { email-link, send }
                                                                           send helper exposes transactionId
                                                                           link opened in the same browser
subflow_step_started/finished { email-link, post-response }
auth_method_decision_started  { password-reset, options: [password-enrollment] }
subflow_started               { password-enrollment, spec: password-reset }
subflow_step_started/finished { password-enrollment, post-response }       resolves password-reset
flow_enriched                 { userId }                                setUser()
flow_finished                 { flowName: recovery }
flow_auto_finished            { flowName: login, finishedByFlowName: recovery }
```

Two things this series shows. An offered `email-link-login` option can resolve through the email-link attempt; a screen without a method choice does not need a separate decision. A link opened on another device has a different session. The receiving page starts a `recovery` flow and calls `continueEmailLinkOperation()` with the sending helper’s transaction ID. Transaction pairing lets the backend continue the initiating recovery. Although the receiving browser has no locally open login, its `flowAutoFinished()` call can close the original `login` when recovery completes.

### 6.5 Bank login with a second factor, a trusted browser and a chained enrollment

A returning customer with a remembered client number. The identifier step is skipped, the password is required, the second factor is an approval in the bank's app, and the approval also registers the browser as trusted until midnight. After login the customer is offered a passkey.

```text theme={null}
flow_started                  { flowName: login, touchpoint: netbank-logon, tags: { known_user: true } }
flow_enriched                 { identifier }                            setUser(), the remembered client
auth_method_decision_started  { post-identifier, options: [forget-client, password-login-known-identifier,
                                switch-to-passkey, back] }
subflow_started               { password-login, spec: password-known-identifier }   password field rendered
subflow_step_started          { password-login, post-response }
subflow_step_finished         { password-login, post-response }         resolves post-identifier
subflow_started               { trusted-device-check, spec: token }     the host checked the cookie
subflow_step_started          { trusted-device-check, post-response }
subflow_step_finished         { trusted-device-check, post-response, result: not-trusted }
auth_method_decision_started  { 2fa, options: [passkey-login-known-identifier, switch-to-app, back] }
auth_method_decision_finished { 2fa, explicitDecisionValue: switch-to-app }
auth_method_decision_started  { 2fa, options: [app-confirmation, try-another-way] }
subflow_started               { app-confirmation }                      the request is shown
subflow_step_started          { app-confirmation, ceremony }
subflow_step_finished         { app-confirmation, ceremony }            the poll saw the approval
subflow_step_started          { app-confirmation, post-response }       the continuation creates the session
subflow_step_finished         { app-confirmation, post-response }       resolves 2fa
subflow_started               { trusted-device-enrollment, spec: token }
subflow_step_started          { trusted-device-enrollment, post-response }
subflow_step_finished         { trusted-device-enrollment, post-response, result: created }
flow_enriched                 { userId, identifier }                    setUser(), host-confirmed
flow_finished                 { flowName: login, tags: { method: app, browser_trusted: true } }
flow_started                  { flowName: enrollment, touchpoint: security-settings }   chained
flow_enriched                 { userId }                                setUser()
auth_method_decision_started  { enrollment-user, options: [passkey-enrollment, skip, dont-ask-again] }
subflow_started               { passkey-enrollment, spec: manual }
subflow_step_started/finished { passkey-enrollment, get-options }
subflow_step_started/finished { passkey-enrollment, ceremony, mediation: required }
subflow_step_started/finished { passkey-enrollment, post-response }     resolves enrollment-user
flow_finished                 { flowName: enrollment }
```

Three things this series shows. The two trusted-device subflows are system work: they belong to no decision and resolve no option, and the negative check is a finished step with a result, not an error. The identifier is attached as soon as it is known, here at bootstrap because the browser remembered the client, otherwise right after the customer submits it. The confirmed user id follows with the login result. The `2fa` checkpoint is presented twice because the customer navigated from the method choice to the app screen, and the second presentation is the one the app confirmation resolves. The enrollment is chained after the login finished, so it is a sibling with its own identity call, and a "don't ask again" would finish it with `explicitOutcome: skipped` exactly like a skip.

## 7. What the classifier merges, repairs or drops

| Signal | Classifier behavior |
| - | - |
| Repeated `flow_started` for the innermost open flow | Merged. Latest touchpoint wins, tags accumulate. |
| Repeated `flow_started` for an outer flow while a nested flow is open | Merged into the outer flow. Every flow nested under it closes as incomplete. |
| `flow_finished` or `flow_decided` without an open flow | Fatal. The whole session scope fails classification. |
| `flow_auto_finished` without an open flow of that name | Reconstructed as an already-closed flow of that name. Meant for the sibling the classifier closed on `flow_decided`; never send it for a flow that was not started. |
| Consecutive `subflow_started` of the same type | Folded into one attempt. A different, known spec type starts a new attempt. |
| Decision `started` re-emitted with the same or changed options | New occurrence. The open one closes as incomplete. |
| Decision `started` re-emitted with the same `explicitTimestamp` | Replaces the open occurrence in place. |
| Decision `finished` without an open decision of that name | A zero-duration completed occurrence. |
| Decision `finished` whose value is not one of the declared options | Completed with that value, but invisible to option-level reports. Keep values inside the declared set. |
| Subflow attempt with no step and no observed input interaction | Not counted. A passkey attempt is the exception: counted as incomplete, resolving no decision. |
| Passkey, password-enrollment, provide-data, email-link, social, SSO or trusted-device attempt without a spec type on any event | Dropped. Password login, identifier entry, OTP, TOTP and app confirmation default instead. |
| Events arriving out of order within a screen | Repaired. The backend orders by timestamp and emission sequence and tolerates known races. |

## 8. Next steps

<CardGroup cols={2}>
  <Card title="Flows" icon="diagram-project" href="/corbado-observe/tracking/flows">
    Flow events, nesting and outcomes.
  </Card>

  <Card title="Decisions" icon="code-branch" href="/corbado-observe/tracking/decisions">
    Presentation rules and option strings.
  </Card>

  <Card title="Subflows" icon="list-check" href="/corbado-observe/tracking/subflows">
    Operation helpers, steps and spec types.
  </Card>

  <Card title="Use with AI agents" icon="robot" href="/corbado-observe/get-started/use-with-ai-agents">
    Let an agent apply this method to your codebase.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.