AUTH4D-160 Flows built-in steps
AUTH4D-160 adds seven step types to the AUTH4D-159 flow definition, a fixed set of denial messages
for hosted login, a terms interstitial on hosted login and a signing secret for call_backend. The
schemas are in packages/contracts (FlowStepSchema, FlowDenyMessageSchema,
FlowInteractionSchema, FlowBackendEventSchema, FlowBackendResponseSchema,
FlowSigningSecretResponseSchema). The step handlers are apps/api/src/modules/flows/engine.ts and
apps/api/src/modules/flows/steps.ts. Storage is migration 0020_flow_steps.sql.
All steps keep the AUTH4D-159 fields: id, onError (fail_closed by default) and when. A step
that needs a user (see the table) fails when the trigger has none (pre_registration), and
onError decides what happens.
Step types
type |
Needs a user | Effect |
|---|---|---|
deny |
no | Denies with reasonCode. AUTH4D-160 adds an optional message (below). |
require_verified_email |
no | Denies unless the user has a verified email address. |
allow_list |
no | Denies unless one of the user’s values for attribute is in values. |
block_list |
no | Denies when one of the user’s values for attribute is in values. |
set_role |
yes | Assigns or removes one of the application’s AUTH4D-154 roles for the user. |
set_claim |
yes | Sets one key of the user’s AUTH4D-154 auth4_meta metadata; null removes it. |
call_backend |
no | POSTs a signed event to your backend, which answers continue, deny or a claims patch. |
terms_screen |
yes | Shows a terms interstitial on hosted login until the user accepts this version. |
{
"timeBudgetMs": 1500,
"steps": [
{ "id": "verified", "type": "require_verified_email" },
{
"id": "regions",
"type": "allow_list",
"attribute": "country",
"values": ["ZA", "NZ"],
"message": "region_unavailable"
},
{
"id": "banned",
"type": "block_list",
"attribute": "steam_id",
"values": ["76561197960287930"]
},
{
"id": "terms",
"type": "terms_screen",
"termsUrl": "https://example.com/terms",
"version": "2026-10"
},
{
"id": "backend",
"type": "call_backend",
"url": "https://hooks.example.com/auth4",
"timeoutMs": 1000
},
{ "id": "player", "type": "set_role", "role": "player" },
{ "id": "tier", "type": "set_claim", "key": "tier", "value": "gold" }
]
}
deny and denial messages
Every step that denies (deny, require_verified_email, allow_list, block_list, a backend’s
deny) can name one of these message keys. Hosted login shows its own wording for the key; a
flow can never supply text that end users see.
message |
Hosted login shows |
|---|---|
not_allowed |
“{App} does not allow this account to sign in.” (the default) |
region_unavailable |
“{App} is not available in your region.” |
account_restricted |
“This account cannot sign in to {App}. Contact the developer of {App}…” |
email_unverified |
“{App} needs a verified email address. Sign in with email to verify one…” |
try_again_later |
“Sign-in could not be completed right now. Try again in a few minutes.” |
A step that fails closed (flow_step_failed, flow_budget_exceeded) shows try_again_later. The
reasonCode is for traces only and is never shown.
require_verified_email defaults to reasonCode email_not_verified and message
email_unverified. Before registration (pre_registration) there is no user yet, so the step
continues only when the user is signing up with an email code or link.
allow_list and block_list
attribute |
Values | Compared with |
|---|---|---|
steam_id |
SteamID64, 17 digits starting 7656 |
the user’s linked Steam accounts |
discord_id |
Discord user id, 15 to 21 digits | the user’s linked Discord accounts |
email_domain |
lowercase domain, for example studio.example.com |
the domain of each verified email address |
country |
ISO 3166-1 alpha-2, uppercase | the edge’s country for the request |
1 to 500 values per list; values are checked per attribute when the flow is saved. Domains match
exactly (a subdomain is a different domain). A user with no value for the attribute (no linked
Steam account, an unknown country, or no user yet before registration) is not on any list, so an
allow list denies and a block list continues. Defaults: reasonCode not_on_allow_list or
on_block_list, message not_allowed.
set_role and set_claim
These reuse AUTH4D-154 storage, so the change appears in the auth4_roles and auth4_meta access
token claims from the next token on, refreshes included, and can be seen and changed in the
management API and console like any other assignment.
set_role:rolemust be defined for the application the trigger runs for, or the step fails.actionisassign(default) orremove. Assignments past the AUTH4D-154 limits (20 roles per user per application, 200 per user) are not made.set_claim:keyfollows the AUTH4D-154 metadata key rules;valueis a string (up to 256 characters), number, boolean ornullto remove the key.
Changes are written only when the whole flow continues, after the last step. A later denial or terms screen writes nothing. If the merged metadata would break the AUTH4D-154 limits (32 keys, 2048 bytes), the trigger is denied.
The AUTH4D-159 patch_claims and patch_roles steps are unchanged: they only shape the hook
decision and are not applied to tokens.
call_backend
url must be https on the default port, without credentials or a fragment, to a public DNS name:
IP literals, single-label names, localhost, .local, .internal, .test, .example,
.invalid, .arpa and other reserved names, and auth4.dev hosts are refused when the flow is
saved, and checked again before every call. Redirects are not followed. timeoutMs is 100 to 1900
(default 1500), and the call is also cut off when the flow’s time budget runs out.
The request is a POST with Content-Type: application/json and this body:
{
"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": "email",
"context": { "country": "ZA", "provider": "email" }
}
}
input is the AUTH4D-158 trigger input: identifiers, the sign-in method and coarse context only,
never email addresses, IP addresses, tokens or secrets.
It is signed the Standard Webhooks way with the project’s signing secret (below):
| Header | Value |
|---|---|
webhook-id |
msg_ plus 24 random characters, unique per call |
webhook-timestamp |
Unix seconds |
webhook-signature |
v1, + base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{raw body} |
The HMAC key is the base64 part of the secret after whsec_. Verify the signature over the raw body
with a constant-time comparison, and reject timestamps more than five minutes old.
The backend must answer 200 with a JSON body of at most 4096 bytes:
{ "action": "continue" }
{ "action": "continue", "claims": { "tier": "gold", "legacy": null } }
{ "action": "deny", "reason_code": "account_banned", "message": "account_restricted" }
claims is a patch of 1 to 16 auth4_meta metadata keys, stored like set_claim (it needs a user).
Any other status, a redirect, a larger or malformed body, unknown fields, reserved or namespaced
claim names, free-text messages, a timeout or a missing signing secret fail the step, and onError
decides.
terms_screen
termsUrl is an https page (at most 512 characters) and version 1 to 32 letters, digits, .,
_ or -. Until the user has accepted this version for this application, the step pauses the
flow:
- On hosted login (
post_loginduring/oauth/authorize) the user sees “Accept the terms of {App}”, a link that opens the page in a new tab, and Accept and continue or Decline. Accepting records the acceptance and runs the flow again from the start; declining returns to the app withaccess_denied. prompt=nonereturnsinteraction_requiredto the redirect URI.- Device approval, guest sign-in and guest upgrades cannot show the screen, so they are denied
(
access_denied) until the user has accepted through a browser sign-in.
The trace records the step as interaction_required and the run as deny with reason code
terms_required. Publishing a new version asks every user again.
Hosted login continuation
POST /oauth/authorize/continue (AUTH4D-23) changes as follows. Responses stay JSON and no-store.
- When a
post_loginflow needs the terms screen, the answer is200 { "interaction": { "type": "terms", "terms_url": "https://…", "terms_version": "2026-10" } }. No code is issued and the flow cookie stays valid. - The next request sends
{ tenant_id, flow_id, decision: "approve", accept_terms: true }(no proof), ordecision: "deny"to decline.accept_termswithout a pending terms screen, with a proof or withdecision: "deny"answers400 invalid_request. - A Flows denial still answers
200 { "redirect_to": "…?error=access_denied&state=…" }, now with"denial": { "message": "<key>" }so hosted login can explain it before returning to the app.
Signing secret
| Method and path | Scope / role | Result |
|---|---|---|
GET /management/tenants/{tenantId}/flow-signing-secret |
tenant:read, any role |
{ configured, createdAt } |
POST /management/tenants/{tenantId}/flow-signing-secret |
tenant:write, owner or admin, {} |
{ secret, createdAt } |
POST creates the project’s secret, or replaces it, and returns it once (whsec_ plus base64 of 32
random bytes). It is stored encrypted with AUTH_ENCRYPTION_KEY_RING and never returned again;
rotate to get a new one. Without the key ring the answer is 503 not_ready. Each rotation writes
the audit event management.flow.signing_secret_rotated; refusals are recorded with outcome
denied. The console reaches both routes through its BFF.
Storage
0020_flow_steps.sql adds flow_terms_acceptances (user, application, terms version, the URL
shown and when) and flow_signing_secrets (one encrypted secret per project). See
d1-schema.md (repository file docs/operations/d1-schema.md).
Source: docs/api-spec/auth4d-160.md