# Flows Fundamentals

From [Descope Fundamentals](https://learn.descope.io/c/foundations) — Learn the platform by configuring your own project — every module ends in work the app checks for real.

Take this course interactively at https://learn.descope.io/c/foundations/flows-fundamentals

---

![Actions and Error Handling. 2 minutes.](video:flows-fundamentals-actions-and-error-handling)

## The kinds of work actions do

- Authentication — starting and finishing a sign-in with social login, passkeys, magic link verification, and the rest.
- User updates — changing details on the user the flow is operating on.
- Tenants and roles — creating a tenant, or associating a role with a user.
- Supporting work — custom claims, generating a JWT, loading a user, checking a rate limit, validating an email address.

A journey that only works on the happy path is not finished. Designing for recovery — what happens when a code expires, a user mistypes, or an external call fails — is what separates a demo flow from a production one, and it is configured per action rather than globally.

## Error handling, set on each action

- Automatic — Descope handles the failure with its default behavior.
- Ignore — the failure is passed over and the flow continues.
- Custom — you decide, and you can branch on which error occurred.

Custom is the one worth reaching for on anything a user will notice. Rather than a generic failure, you can route to a screen that explains what went wrong and offers a different authentication method — which turns a dead end into a second chance, and is usually the difference between a support ticket and a completed sign-in.

> [!NOTE]
> Connector steps expose a more granular set of behaviors than the three above, because a call to somebody else's API fails in more interesting ways than an internal action does. Those are covered in the Connectors and Extensibility module.

![The Error handling section of an action's settings, one row per failure the action can produce — a send failure, a rate limit, a tenant that requires SSO, a disabled tenant. Each row has its own Handling dropdown, which is what makes recovery a per-error decision rather than a global switch. The Custom error message beside it replaces Descope's wording; choosing Custom in the dropdown is what lets you route to a screen of your own instead.](flows-fundamentals-actions-and-error-handling)

## Summary

- That actions are the logic layer, and the breadth of what they cover
- How to add and wire one in the flow builder
- The three error-handling settings available on an action, and when Custom earns its keep
- That a recoverable journey is a design decision made per action, not a global setting

---

![Conditions and Dynamic Values. 2 minutes.](video:flows-fundamentals-conditions-and-dynamic-values)

## What people branch on in practice

- Requiring extra verification when a sign-in looks high-risk
- Showing different screens depending on the user's country
- Choosing an authentication method based on the user's email domain
- Skipping steps a returning user has already been through

You add one from the flow builder's blue +, then design it by picking a key and an operator. "Is this a new user?" is the canonical example: the key is authInfo.firstSeen and the operator is Is True. If it is not true, the user already exists, and the other branch handles them. When a condition has several clauses you can drag them to change the order they evaluate in.

The keys themselves are dynamic values — attributes Descope resolves at run time. They are not exclusive to conditions: the same keys populate messaging templates, feed connectors and actions, and can be displayed on a screen, which is why one vocabulary is worth learning once.

Every running flow carries a bag of values called the flow context, and the keys above are how you read out of it. It is built up as the run proceeds rather than handed over complete at the start.

## What puts values in the flow context

- **Descope**, from the outset — what it already knows about the visitor and their device, under `user.*` and `authInfo.*`.
- **Screens**, as the user fills them in — whatever a form collects becomes readable by later steps.
- **Connectors and scriptlets**, when they return — a connector's response lands under `connectors.<name>`, a scriptlet's under the context key you name.

Two consequences follow, and both are behind failures later in this module. A key only exists *after* the step that produced it, so a condition placed before a connector cannot read that connector's response. And the context belongs to one run: it is built fresh each time a flow starts and is gone when it ends, which is why the Flow Runner starts with almost nothing in it.

## A sample of the user.* keys

- user.email and user.emailDomain — the address, and just its domain, which is what tenant routing usually keys on
- user.verifiedEmail and user.verifiedPhone — whether each identifier has actually been proven
- user.userTenants, user.tenantIds, user.tenant.roles — tenant membership and the roles held within one
- user.fingerprint.knownDevice — whether an unauthenticated visitor is on a device seen before
- user.lastAuth.country — where the previous sign-in came from
- user.status — enabled, invited, or disabled
- user.test — true for a test user, which is how you keep CI traffic out of real branches

> [!NOTE]
> There are also purpose-built conditions for the checks people write most often, including user.loggedIn, IP address checks, disposable and free email detection, failed password attempts, remaining OTP attempts, and whether SSO is enforced. Reach for those before hand-rolling the equivalent.

![A condition being designed. The If branch is a key, an operator and a value — here `app.clientId` Contains `XYZ` — and the `+` beside the key adds another clause to the same branch. `Else if +` adds further branches below it, and the Else at the bottom catches everything the clauses above did not. Treat as an error turns a false branch into a failure the flow can handle rather than just another path.](flows-fundamentals-conditions-and-dynamic-values)

## Summary

- That a condition is a key plus an operator, and how clause order is controlled
- The canonical new-versus-returning branch and the key behind it
- That dynamic values are one vocabulary shared by conditions, actions, connectors, templates and screens
- Which purpose-built conditions already exist, so you do not rebuild them

---

![Connectors in Flows. 2 minutes.](video:flows-fundamentals-connectors-in-flows)

A flow only knows what Descope knows. A connector is how it reaches anything else mid-journey: your own API, a fraud check, a CRM, a messaging provider. The Connectors module covers configuring them; this is about what happens when one sits inside a flow.

Configuration and use are deliberately separated. The connector itself — base URL, credentials, headers — is set up once on the Console's Connectors page. A flow then adds a connector step that references it and fills in only what varies per call. Flow authors never see the underlying secrets, which is the point of the split.

The step's response lands in flow context under its Context Key, and later steps read it by path. A connector keyed userLookup returning a nested object is read as connectors.userLookup.user.profile.role — the same dotted vocabulary conditions and actions already use.

## What can consume a connector's response

- A condition, branching on something the external service said
- A Custom Claims action, putting an external value into the token
- An Update User Properties action, writing it onto the user record

> [!WARNING]
> Connector steps run synchronously by default, and that is usually what you want. Marking one to run asynchronously is for genuine fire-and-forget work like an analytics event, and it has a consequence people trip over: an async step never writes its response to flow context, whether or not the call succeeded. A later condition reading connectors.analytics.status will find nothing there.

Two more things worth knowing before you depend on one. There is no built-in retry — a timed-out call is a timed-out call, and retrying is something you model as flow logic. And branching on an HTTP status takes two pieces, not one: custom error handling enabled on the step, plus a condition reading connectors.<Context Key>.statusCode.

![A connector step on the canvas, in its own orange color so it reads as the one block that leaves Descope. The step is a Generic HTTP / GET and its single output here is Success, wired on to the action that follows. Branching on anything more specific than that — a particular status code — takes custom error handling on the step plus a condition reading `connectors.<Context Key>.statusCode`.](flows-fundamentals-connectors-in-flows)

## Summary

- Why configuration and use are separated, and what that buys a flow author
- How a response lands in flow context and how later steps address it
- What sync versus async costs you, and the silent gap async leaves in context
- That retries are your design problem, and what branching on a status code actually requires

---

![Flow Builder Fundamentals. 4 minutes.](video:flows-fundamentals-flow-builder-fundamentals)

## The four core block types

- Screens — the UI layer. A customizable form the user sees: login fields, profile inputs, a consent checkbox.
- Actions — the logic layer. A single task such as verifying a password, sending a one-time code, or creating a user.
- Conditions — the branching layer. Evaluates facts about the user, device, or request and routes down different paths.
- Connectors — the integration layer. Talks to an outside service mid-flow: your API, a fraud check, a CRM.

## Two more that round out real flows

- Scriptlets — a small piece of custom JavaScript for when no built-in action fits: hashing, date math, string formatting.
- Subflows — one flow embedded inside another, so shared logic is written once.

Descope has two families of flow. Interactive Flows are the user-facing journeys — login, registration, password reset, MFA. Management Flows are backend automations for administrative tasks; they run with no user at a screen and are usually kicked off by a system event such as a user being created or a tenant updated. Throughout this course, "flow" means an interactive flow unless stated otherwise.

The same flow logic reaches users two ways, and choosing between them is one of the first integration decisions you make. Embedded means dropping Descope's client SDK component into your own app and pointing it at a flow by ID; Descope renders the screens inside your page and stores the resulting token, which gives you the most control over surrounding design. Hosted means Descope serves the flow at its own Auth Hosting URL — nothing to embed, you redirect users there and they come back authenticated — which suits redirect-based and federated OIDC/SAML setups. The flow you built is identical either way; only the delivery differs.

Every interactive flow ends at an End step. Reaching it successfully issues two JWTs: a short-lived session token, signed so your backend can validate it on its own using Descope's public key with no call back to Descope per request; and a longer-lived refresh token, exchanged for a fresh session token when that one expires. If the refresh token is invalid or expired, the user logs in again. The session token is a wristband at an event — the gate checks the signature quickly without phoning the box office, and it is only good for a while.

## Common JWT claims

- sub — the user's ID
- iss — the issuer, your Descope project ID
- exp and iat — expiry and issued-at timestamps
- amr — the authentication methods used, e.g. oauth
- drn — the Descope token type

> [!NOTE]
> The session token is the handoff point between Flows and the rest of your application. Everything in the Sessions and Tokens module starts from the token a Flow produces here.

## Key terms

- Flow — a visual workflow defining a complete user journey, outputting a session token.
- Flow Builder — the Console canvas where you place and connect blocks.
- Flow ID — the identifier your app uses to select which flow to run.
- Session token (session JWT) — short-lived, signed, validated by your backend to identify the user.
- Refresh token (refresh JWT) — longer-lived, exchanged for new session tokens.
- Embedded flow — rendered inside your app via the client SDK component.
- Hosted flow / Auth Hosting — served on Descope's hosted page, or a self-hosted copy.
- Interactive vs. Management Flow — user-facing journey vs. event-triggered backend automation.
- End step — the terminal block; reaching it issues the JWTs.

![A sign-in flow on the Flow Builder canvas, running left to right from START to END. Blue blocks are screens the user sees and purple ones are actions that run without them: the sign-in screen branches to either Magic Link or OAuth, the magic-link path waits on its own screen while polling, and both paths converge on the same end.](flows-fundamentals-flow-builder-fundamentals)

## Summary

- What a Flow is, and that the arrows are the orchestration
- The six block types and which layer each one represents
- The two delivery modes, embedded and hosted, and when each fits
- That a successful Flow yields a session JWT plus a refresh JWT, and which one your backend validates alone

---

![Flow Testing and Debugging. 3 minutes.](video:flows-fundamentals-flow-testing-and-debugging)

Before a flow reaches real users you have to validate it, and when something goes wrong you need to see inside. The Flow Runner runs your flow live inside the Console: open a flow in the Flows tab and click Run at the top right. As the flow executes it writes messages, action outputs, and errors into the Runner messages panel. For connector steps it logs the exact request Descope sent and the response it received, which is usually the fastest way to pinpoint an integration bug.

Test deliberately rather than opportunistically: walk each branch as its own scenario — new versus returning user, valid versus invalid input, trusted device versus risky signals — so you confirm both sides of every condition rather than only the path you happened to take.

> [!WARNING]
> The Flow Runner starts a fresh, unauthenticated session by default, so user.loggedIn is false and any logic depending on an existing session takes the unauthenticated path. That is why step-up, profile-update, and impersonation flows appear broken in the runner when they are fine in production.

To simulate a logged-in user, click Run, then Configure, add an Input with the key jwt, and paste a valid refresh JWT for the user you want to test as. The runner then establishes that session, populating user.loggedIn, the user.* values, and jwtClaims. Supplying that JWT sets session context only — it does not automatically skip authentication steps. Steps are skipped only if the flow itself branches on user.loggedIn.

For issues real users hit, the Console's Troubleshooting Logs record flow failures with the step name and error details, including the complete error response for connectors, and can be streamed to your own systems via audit stream connectors by enabling Stream Troubleshooting Events. Separately, Flow Activity traces an execution end-to-end with per-step status and timing, so you can follow exactly where a run went wrong.

## Debugging in the browser

- debug=true — append it to a hosted flow URL to enable the in-browser debugger panel.
- SDK debug mode — the equivalent debug option on the client Flow component, for flows embedded in your own app.
- Flow Version Mismatch — an out-of-sync error, most common with conditions placed before any screen on a page that has been rendered for a long time.
- restartOnError — a client SDK flag that restarts the flow rather than failing, which prevents most version-mismatch errors.

## Key terms

- Flow Runner — the Console tool that runs a flow live and writes messages, outputs, and errors.
- Runner messages — the panel showing step output, errors, and connector request/response logs.
- Test scenario — a deliberate run down one branch.
- Refresh JWT input — a jwt input in the runner's Configure panel that simulates a logged-in user.
- Troubleshooting Logs — Console logs of flow failures with step names and full connector responses.
- Flow Activity — an end-to-end trace of one flow execution with per-step status and timing.

![The Runner messages panel after a failed run, open beneath the canvas. Two errors: the underlying 404 fetching the flow page, and the `Cannot destructure property 'body'` that followed from it. Reading them in that order is the habit worth forming — the second message is usually the consequence, and the first is the one to fix.](flows-fundamentals-flow-testing-and-debugging)

## Summary

- How to run a flow in the Console and what the Runner messages panel shows for connectors
- Why authenticated flows misbehave in the runner, and how a jwt input fixes it
- That supplying a refresh JWT sets context but does not skip steps
- Where to look when a production user reports a failure

---

![Screens and Components. 2 minutes.](video:flows-fundamentals-screens-and-components)

## The components you assemble from

- Inputs — capture user details like email addresses and phone numbers.
- Buttons — let users pick an action, including a login method such as SSO or a social provider.
- Text — displays login errors and success messages.
- Links — point at your privacy policy, terms of service, or anywhere else.
- Images and logos — your brand's mark, or any custom image.
- Containers — layer and position everything else.

Components can also react to the run they are in. Click the Conditions button at the top left of the Screen Builder, define a condition, and name the component it applies to. The classic case is a device that does not support WebAuthn: rather than offering a passkey button that cannot work, you hide it.

## Three things a condition can do to a component

- Hide it — the component is not displayed at all.
- Disable the input — still visible, but cannot be interacted with.
- Set the input read-only — visible, and its value can still be copied, but not edited.

> [!NOTE]
> Read-only rather than disabled is the right choice whenever the user needs to copy the value. A TOTP seed key is the obvious example: disable it and they cannot select it, which defeats the point of showing it to them.

One thing worth knowing before you start writing copy into components: the labels, placeholders and other strings are translatable, but the translations live in the Console's Localization section rather than in the screen itself. Editing a label here changes the default, not every language.

![The screen editor, opened on a sign-in screen. Components are dragged in from the left — this project offers 32 inputs alone, including identity-aware ones like Switch Tenant and Impersonated User that you would otherwise build yourself — and the panel on the right styles whatever is selected. The light and dark toggles at the top preview both themes, and Conditions above the canvas shows parts of a screen conditionally rather than branching the whole flow.](flows-fundamentals-screens-and-components)

## Summary

- That screens are assembled from components in the Screen Builder rather than written as markup
- Which component does which job, and that containers handle layout
- How a condition can hide, disable, or make a component read-only, and why read-only exists
- That translated strings live in Localization, not on the screen

---

![Scriptlets and Subflows. 2 minutes.](video:flows-fundamentals-scriptlets-and-subflows)

## The two tools, and when each fits

- Scriptlet — a small block of JavaScript that runs inside a flow. For shaping a value: reformatting a phone number, deriving a field, turning one connector's response into the shape another step needs.
- Subflow — a whole flow embedded as a single step in another flow. For reusing a journey: one MFA sequence referenced from every flow that needs it, changed in one place.
- The dividing line is what you are reusing. A few lines of logic is a Scriptlet; a sequence of screens and steps is a subflow.

## How a Scriptlet passes values

- Arguments — what you hand in, drawn from the flow context using the same `user.*` and `connectors.*` keys conditions use.
- Context key — the name its return value is stored under, which is how later steps address the result.
- That pairing is the whole interface: read from context through arguments, write back to context through the context key.

Screens, actions, conditions and connectors cover most of what a journey needs, and their outputs are fixed shapes Descope writes into flow context. Scriptlets and subflows are the two escape hatches: one for logic that has no built-in equivalent, the other for logic you have already built once.

A Scriptlet runs custom JavaScript inside the flow and writes what it returns back into context. Reach for it when the thing you need is small and shapeless — hashing, date math, a string transform — and no action does it. Values come in as named arguments, either dynamic (read from context at run time, like form.displayName) or static, typed as string, boolean, number or time.

Whatever your script returns is stored under the context key you configure, defaulting to a path like scripts.scriptletResult, and later actions and conditions read it from there like any other value. Lodash and CryptoJS are both available in the runtime, so iteration helpers and hashing do not need reimplementing.

> [!WARNING]
> Secure random generation is not available in a Scriptlet — CryptoJS.lib.WordArray.random() will not work. Math.random() is fine for anything non-cryptographic, but if you find yourself wanting a secret generated here, that is a sign the work belongs on your backend behind a connector instead.

A subflow is any flow embedded inside another. Add one from the flow builder's + by choosing Flow and picking from your library — every flow is eligible except the one you are editing. It appears as a single collapsed step with an aggregated view, and double-clicking opens it in its own tab beside the main flow. Subflows can themselves contain subflows.

> [!NOTE]
> The part that catches people: an End step inside a subflow does not end the journey. Any End step, and any output the subflow leaves unhandled, becomes an output of the subflow step in the main flow — and you have to link it there. A subflow with dangling outputs is the same stalled-run problem as any unconnected block, just one level down.

Where the main flow references a subflow, that relationship is visible from the other side too: the Used In column tells you which flows depend on the one you are looking at. Worth checking before editing something that looks self-contained.

![A Scriptlet on the canvas, wired to the step that consumes what it returns. It carries the same purple action icon as any other action and exposes a single Success output — the custom JavaScript is behind the gear in the toolbar above the selected block, and the value it returns is addressed from later steps by the context key you give it.](flows-fundamentals-scriptlets-and-subflows)

## Summary

- When a Scriptlet is the right tool, and how arguments and the context key work
- Which libraries are available, and that secure randomness deliberately is not
- How to embed a subflow, and that they nest
- That an End step inside a subflow becomes an output to wire up, not a terminus
- Where to check what depends on a flow before you change it

---
