Flows step reference

This page describes each step a flow can hold, what it reads and what it does. Steps run in order; see Flows for triggers, versions and what happens when a step fails.

Fields every step has

FieldConsoleRules
idStep idUnique in the flow. Lowercase letters, digits, _ and -, up to 32 characters.
typeThe step card's nameOne of the types below.
onErrorIf the step fails or times outfail_closed (default) denies the sign-in; fail_open passes over the step.
whenRun only when (optional)methods and countries lists. The step runs only when every list given matches.

Some steps need a player. Before registration there isn't one yet, so those steps fail there, andonError decides what happens.

Denial messages

Every step that can deny takes a reasonCode and a message. The reason code is yours: it appears in traces and is never shown to players. The message is one of these keys, and hosted login shows its own wording for it. A flow can't supply text that players see.

messageConsoleHosted login says
not_allowedNot allowedThe app doesn't allow this account to sign in. This is the default.
region_unavailableNot available in this regionThe app isn't available in the player's region.
account_restrictedAccount restrictedThis account can't sign in to the app; contact its developer.
email_unverifiedEmail not verifiedThe app needs a verified email address.
try_again_laterTry again laterSign-in couldn't be completed right now. Also shown when a step fails closed.

Your app receives the standard access_denied error either way. Games using device sign-in see the denial on their next poll.

Deny

deny (Deny) always refuses. Combine it with when or a condition, or use it on its own while a build should let nobody in.

{ "id": "closed-beta", "type": "deny", "reasonCode": "beta_closed", "message": "not_allowed" }

Require a verified email

require_verified_email (Require a verified email) refuses players with no verified email address. Defaults: reason code email_not_verified, messageemail_unverified. Before registration, it continues only when the player is signing up with an email code or link. Steam and Epic Games accounts have no email address, so this step refuses them until the player adds one.

Allow list and block list

allow_list (Allow list) refuses players unless one of their values is listed; block_list (Block list) refuses them when one is. Each list holds 1 to 500 values of one attribute (Match on in the console).

attributeValuesCompared with
steam_idSteamID64: 17 digits starting 7656The Steam accounts linked to the player
discord_idDiscord user ID, 15 to 21 digitsThe Discord accounts linked to the player
email_domainLowercase domain, such as studio.example.comThe domain of each verified email address. Subdomains don't match.
countryTwo-letter country code, such as ZAThe country Cloudflare's edge reports for the request
{
  "id": "studio-only",
  "type": "allow_list",
  "attribute": "email_domain",
  "values": ["studio.example.com"],
  "message": "not_allowed"
}

A player with no value for the attribute isn't on any list: an allow list refuses them and a block list lets them through. That includes every player before registration, except forcountry. The edge country is a best guess from the network and can be wrong or missing. Defaults: reason code not_on_allow_list or on_block_list, message not_allowed.

Condition

condition (Condition) checks an Expression and then continues, skips the next steps, or denies. then (When true) and else(When false) each default to continue, and at least one of them must do something else.

{
  "id": "geo",
  "type": "condition",
  "expression": { "in": [{ "var": "country" }, ["KP", "IR"]] },
  "then": { "action": "deny", "reasonCode": "region_blocked", "message": "region_unavailable" },
  "else": { "action": "continue" }
}

Expressions are a small part of JSONLogic: var,==, !=, !, and, or,if, the comparisons and in. There is no truthiness: an operator that needs a boolean refuses anything else. These fields can be read:

FieldTypeNeeds a player
trigger, methodstringno
country, providerstring or nullno
application.idstringno
user.idstringyes
user.is_guest, user.email_verifiedbooleanyes
user.email_domainslist of stringsyes
identities.providerslist of stringsyes
identities.countnumberyes
provider_ids.steam, provider_ids.discordlist of stringsyes
roleslist of stringsyes

A skip branch passes over the next 1 to 15 steps (Steps to skip). Publishing checks every expression against the flow's trigger and reports the field that's wrong. The condition reference has the operators and limits in full.

Store a role and store metadata

set_role (Store a role) assigns or removes one of the application's roles for the player. The role must already be defined for the application the sign-in is for. set_claim (Store metadata) sets one key of the player's metadata; null removes it.

{ "id": "player", "type": "set_role", "role": "player", "action": "assign" }
{ "id": "first-seen", "type": "set_claim", "key": "first_seen", "value": true }

Both need a player. Changes are stored only when the whole flow continues, and they stay: they appear in the auth4_roles and auth4_meta access token claims from the next token on, refreshes included, and you can see and change them in the console. The usual limits apply: 20 roles per player per application, and 32 metadata keys in 2,048 bytes.

Add token claims and add token roles

patch_claims (Add token claims) and patch_roles (Add token roles) change the access token of the sign-in the flow lets through, without storing anything. They work in post_login flows (browser sign-ins, prompt=none and guests) anddevice_approval flows, where the device's tokens get them.

{ "id": "tier", "type": "patch_claims", "claims": { "tier": "gold", "beta": true } }
{ "id": "tester", "type": "patch_roles", "add": ["tester"], "remove": [] }

Claims are added to the auth4_meta object and roles to auth4_roles, next to the player's stored metadata and roles. When a claim has the same key as stored metadata, the claim wins in the token. Claim names follow the metadata key rule: a lowercase letter, then up to 39 lowercase letters, digits or _. Values are text up to 256 characters, numbers or true/false; null removes a claim an earlier step added. remove takes back roles an earlier step added; it doesn't touch stored roles, so use Store a role for those.

The usual limits apply to the flow's own claims and roles: 32 keys in 2,048 bytes, and 20 roles. The step that goes over fails, and onError decides. If the claims or roles fit on their own but not together with the player's stored ones, that part of the patch is left out of the token and the stored values are used alone, so a change to stored data never locks players out.

Names such as sub, email, scope and anything starting withauth4 are refused, and claims never appear at the top level of a token, so a flow can't change a claim auth4.dev sets. The ID token never carries them.

The patch stays with the session: refreshed tokens carry it too, while stored roles and metadata are read again on every refresh. It ends with the session. The next sign-in runs the flow again, and a guest upgrade starts a new session without it. Flows saved before this change may usenamespace:name claim names. They still run, but those claims never reach a token, and a draft that uses them can't be published until you rename them.

Call your backend

call_backend (Call your backend) sends a signed request to your server and follows its answer. Use it when the decision needs your own data, such as bans kept by your game servers.

{
  "id": "backend",
  "type": "call_backend",
  "url": "https://api.mygame.dev/auth4/flow-step",
  "timeoutMs": 1000,
  "onError": "fail_open"
}

The Backend URL must be https on the default port and name a public host. IP addresses, localhost, private names such as .internal or.local, and auth4.dev hosts are refused, both when the flow is saved and before each call. Redirects aren't followed. timeoutMs is 100 to 1,900 ms, default 1,500, and the call also stops when the flow's time budget runs out.

The request

What your server receives
POST /auth4/flow-step HTTP/1.1
Content-Type: application/json
webhook-id: msg_…
webhook-timestamp: 1791370800
webhook-signature: v1,…

{
  "type": "flow.step",
  "version": 1,
  "step": "backend",
  "sent_at": "2026-10-07T09:00:00.000Z",
  "input": {
    "version": 1,
    "trigger": "post_login",
    "tenant_id": "ten_…",
    "application_id": "cli_…",
    "user_id": "usr_…",
    "method": "steam",
    "context": { "country": "ZA", "provider": "steam" }
  }
}

input has the trigger, the project, the application, the sign-in method and the edge country. After registration it also has the player's user ID. It never holds email addresses, IP addresses, tokens or secrets.

The answer

Answer with status 200 and one of these JSON bodies, at most 4 KB:

{ "action": "continue" }
{ "action": "continue", "claims": { "tier": "gold", "legacy": null } }
{ "action": "deny", "reason_code": "banned_player", "message": "account_restricted" }

claims stores 1 to 16 metadata keys, like Store metadata, so it needs a player. Any other status, a redirect, a larger or malformed body, an unknown field, a free-text message or a timeout fails the step, and onError decides.

Check the signature

Each request is signed in the Standard Webhooksformat with the project's flows signing secret, whsec_…. The secret is shown once when it's created. Without one, every call_backend step fails.

To create it, open Settings, select Open the flows storyboard, and inFlows signing secret select Create secret. Copy the secret into your backend's secret store before you select I've saved the secret. If you lose it, select Rotate secret to get a new one. Only owners and admins can create or rotate it.

After a rotation, calls are signed with both the new secret and the old one for 24 hours. Thewebhook-signature header then holds two space-separated signatures, new first, and a request is genuine when either one matches. Deploy the new secret to your backend within that time. The section shows Old secret works until with the time, andEnd now stops the old secret at once, for example after a leak. Rotating again stops any older secret straight away, so only one old secret is ever still signing.

The management API does the same withPOST /management/tenants/{projectId}/flow-signing-secret; see the signing secret reference.DELETE /management/tenants/{projectId}/flow-signing-secret/previousends the overlap; see the overlap reference.

The TypeScript server SDK checks requests with verifyFlowStepRequest. It reads the raw body, checks the signature with a constant-time comparison, refuses timestamps more than five minutes away from your clock, and returns the parsed event. flowStepResponse checks your answer before sending it, so a mistake shows up in your own logs rather than as a failed step.

A call_backend endpointExcerpt from examples/server/shared/flow-step.ts
let step;
try {
  // secret is the project's whsec_… flows signing secret, kept in your secret store.
  step = await verifyFlowStepRequest(request, { secret });
} catch (error) {
  if (error instanceof FlowStepVerificationError) return flowStepRejectedResponse();
  throw error;
}

const { input } = step.event;
// Before registration there is no user yet, so there is nothing to look up.
if (!("user_id" in input)) return flowStepResponse({ action: "continue" });

if (await players.isBanned(input.user_id)) {
  return flowStepResponse({
    action: "deny",
    reason_code: "banned_player",
    message: "account_restricted",
  });
}
const profile = await players.ensureProfile(input.user_id);
return flowStepResponse({ action: "continue", claims: { tier: profile.tier } });

The full example is examples/server/shared/flow-step.ts. In a Worker or any server that uses the Fetch API Request, route the step's URL to it:

Routing the step in a Worker
import { handleFlowStep } from "./flow-step";

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    if (request.method === "POST" && url.pathname === "/auth4/flow-step") {
      return handleFlowStep(request, env.AUTH4_FLOWS_SIGNING_SECRET, players(env));
    }
    return new Response("Not found", { status: 404 });
  },
};

Without the SDK, use verifyFlowStepSignature(rawBody, headers, { secret })from Express or another framework, passing the body as the exact text received. The webhook verification code also works for flow steps, because both use the same format. webhook-id is unique per call; store it if you want to refuse a repeated request.

Terms screen

terms_screen (Terms screen) asks the player to accept your terms on hosted login. It needs a player. Until they accept this Terms version for this application, the flow pauses and hosted login shows a link to your Terms page URL with Accept and continue and Decline. Accepting runs the flow again from the start; declining returns to your app with access_denied.

{
  "id": "terms",
  "type": "terms_screen",
  "termsUrl": "https://mygame.dev/terms",
  "version": "2026-10"
}

Device approval, guest sign-in and guest upgrades can't show the screen, so they're denied until the player has accepted through a browser sign-in. A sign-in with prompt=none gets interaction_required. Changing version and publishing asks every player again.

Custom code

custom_code (Custom code) runs your own JavaScript and answers like a backend call. It needs the Custom code steps add-on; see Flows custom code.