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
| Field | Console | Rules |
|---|---|---|
id | Step id | Unique in the flow. Lowercase letters, digits, _ and -, up to 32 characters. |
type | The step card's name | One of the types below. |
onError | If the step fails or times out | fail_closed (default) denies the sign-in; fail_open passes over the step. |
when | Run 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.
message | Console | Hosted login says |
|---|---|---|
not_allowed | Not allowed | The app doesn't allow this account to sign in. This is the default. |
region_unavailable | Not available in this region | The app isn't available in the player's region. |
account_restricted | Account restricted | This account can't sign in to the app; contact its developer. |
email_unverified | Email not verified | The app needs a verified email address. |
try_again_later | Try again later | Sign-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).
attribute | Values | Compared with |
|---|---|---|
steam_id | SteamID64: 17 digits starting 7656 | The Steam accounts linked to the player |
discord_id | Discord user ID, 15 to 21 digits | The Discord accounts linked to the player |
email_domain | Lowercase domain, such as studio.example.com | The domain of each verified email address. Subdomains don't match. |
country | Two-letter country code, such as ZA | The 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:
| Field | Type | Needs a player |
|---|---|---|
trigger, method | string | no |
country, provider | string or null | no |
application.id | string | no |
user.id | string | yes |
user.is_guest, user.email_verified | boolean | yes |
user.email_domains | list of strings | yes |
identities.providers | list of strings | yes |
identities.count | number | yes |
provider_ids.steam, provider_ids.discord | list of strings | yes |
roles | list of strings | yes |
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
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.
examples/server/shared/flow-step.tslet 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:
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.