Run a sign-in step
POST /fapi/v1/transactions/current/steps/{step} · called from the environment's sign-in host
Runs one step of the current transaction. This version accepts the identifier and password steps. Every failed
credential step returns the same 401 invalid_credentials, so the response never reveals whether an account exists. When
the password step completes the transaction, the session cookies __Host-idp_session (and, when needed,
__Host-idp_device) are set and the transaction cookie is removed.
Request
Headers:
| Header | Required | Description |
|---|---|---|
Idp-Client |
yes | SDK identifier. |
Origin |
yes | Must be exactly https://<sign-in host>. |
Idp-Csrf-Token |
yes | The csrf_token from the transaction object. |
Path: step, the name of the step (identifier, password, sso_start).
On the otp_send step, next_steps[].methods lists the passwordless methods the effective policy allows (email_otp, magic_link); a client offers only these.
Body (fields depend on the step; next_steps[].fields lists them):
| Field | Type | Description |
|---|---|---|
identifier |
string | Email address, phone number or username (identifier step), up to 320 characters. |
password |
string | The password (password step), up to 128 characters. |
connection_id |
string | A sso_connections[].connection_id value (sso_start step). |
Response
200 OK with the transaction after the step. A completed transaction has no csrf_token.
When the environment requires a second factor and the user has none yet, a user still inside the environment's
mfa.enrollment_grace_days signs in anyway and the completed response carries mfa_enrollment:
required_by (RFC 3339, the end of the grace, computed by the server) and methods (webauthn, totp: the factors the
user can enrol now, in screen order). Show the date and let the user continue; enrolment is added in the account
settings. The field is absent when there is no grace, the user meets the policy, or enrolment is required at once. An
application's min_acr and the requested acr_values are never postponed by the grace.
Enterprise SSO (home realm discovery)
When the identifier step receives an email address whose domain belongs to an enterprise connection, the sign-in
continues with that connection. Only the part after @ is used for discovery.
- One connection: the transaction moves to
federated_redirectand the response carriesredirect_url, the identity provider start address. Navigate the top-level window there. The address contains a one-time token: do not store or log it. - Several connections: the transaction stays in
identifier_pendingand the response listssso_connections(connection_id,display_name,protocol). Showdisplay_nameas text and send the user's choice with thesso_startstep (identifierandconnection_id). A connection that is not offered for the domain returns422 validation_failed. - No connection, or discovery is temporarily unavailable: the normal sign-in continues, so an outage never blocks
the other sign-in methods.
sso_startis an explicit choice and returns503 temporarily_unavailableinstead.
When the sign-in request names an organization that requires SSO, the transaction offers only that organization's
connections: the password and email code steps are not offered, and the passkey stays available only when the
organization has emergency (break-glass) accounts. Creating the transaction already starts the only connection
(federated_redirect with redirect_url) or lists several in sso_connections; send the choice with sso_start
and connection_id alone, identifier is optional here. Under this requirement discovery does not fall back to
other methods: when enterprise sign-in cannot be reached, the step returns 503 temporarily_unavailable. The same
applies when the email domain belongs to an organization that requires SSO for its verified domains.
After the identity provider, the browser returns to /u/sso/complete on the sign-in host; the hosted page finishes
the sign-in.
Linking enterprise sign-in to an existing account
When an enterprise connection does not vouch for email addresses and the address it asserts is already verified on an
account of the environment, the enterprise sign-in does not open a session. The browser comes back to the hosted sign-in
page with a new transaction whose object carries account_link: connection_name and, when the connection belongs to
an organization, organization_name. Tell the user that they already have an account and must sign in to it to link
the connection; never name the account. In this transaction sso_start and magic_link are not offered, and the
identifier step does not route to an enterprise connection.
The user signs in to the existing account with its own methods; a user with a second factor enrolled is asked for it.
The step that completes the transaction opens no session: the connection is linked to the account, and the response is a
new transaction in federated_redirect with redirect_url for the same connection. Navigate the top-level window there;
the enterprise sign-in now finishes as usual. If enterprise sign-in cannot be started, the new transaction is
identifier_pending and an ordinary sign-in follows.
Completing with another account, without the enrolled second factor, or with a sign-in older than the link request links
nothing and opens no session: 409 transaction_state_conflict. A link request that has expired (10 minutes) or was
already used returns 410 transaction_expired. A link request is used once, whatever the outcome. To decline, call
POST /fapi/v1/transactions/current:cancel.
Organization policy
When the authorization request is bound to an organization, the organization's sign-in policy is checked again after the first factor and just before the session is opened:
- The client's IP address is not in the organization's allowlist:
403 ip_not_allowed. - The transaction's factors do not meet the organization's policy (a method the organization does not allow, a missing
factor, or a security key or passkey outside the organization's AAGUID allowlist):
403 no_allowed_method. - The organization's policy cannot be read:
503 temporarily_unavailable. The sign-in fails closed and never continues without the policy.
No session is opened. A refusal when the transaction completes removes the transaction cookie; a 503 after the first
factor leaves the transaction where it was, and the step can be retried. The error body never names the organization,
the user or the IP address.
DPoP-bound headless transactions
For a transaction started with a DPoP header, send a new proof from the same key (htm POST, htu the step URL)
instead of Idp-Csrf-Token; no cookie is used. The step that completes the transaction returns
authorization_response once:
{
"state": "completed",
"authorization_response": { "code": "<code>", "state": "<your state>", "iss": "https://example.keenauth.app" },
"next_steps": [],
"schema_version": 1,
"expires_at": "2026-11-02T10:15:00Z"
}
Check that state and iss match, then exchange the code at /oauth2/token with your code_verifier and a DPoP proof
from the same key. A proof from another key returns invalid_grant. If the authorization request cannot end in a code
(consent, legal terms, an organization choice or a new sign-in is needed, or access is denied), the step returns
403 authorization_failed and the error member carries the OAuth error code; no code is issued.
Errors
| Status | Code | When |
|---|---|---|
| 401 | invalid_credentials |
The identifier or password is not accepted. |
| 403 | origin_not_allowed |
Origin or Idp-Csrf-Token is missing or wrong. |
| 403 | ip_not_allowed |
The client's IP address is not in the organization's allowlist. |
| 403 | no_allowed_method |
The sign-in does not meet the organization's policy. |
| 403 | test_feature_not_in_production |
otp_verify or email_2fa got the fixed test code 424242 in a production environment. The attempt is counted like a wrong code. |
| 404 | not_found |
The step name is not a known step. |
| 409 | transaction_state_conflict |
The step is not available in the current state. |
| 409 | session_limit_reached |
The user has reached the session limit and new sessions are denied. |
| 410 | transaction_expired |
The transaction has ended. |
| 422 | validation_failed |
A field is missing or invalid. |
| 503 | temporarily_unavailable |
sso_start could not reach enterprise sign-in, or the organization's policy could not be read; try again. |
| 401 | invalid_dpop_proof |
The DPoP proof is missing, invalid or reused. |
| 403 | authorization_failed |
The DPoP-bound transaction completed but its authorization request ended without a code. |
Example
curl -X POST https://example.keenauth.app/fapi/v1/transactions/current/steps/identifier \
-H "Idp-Client: my-app/1.0.0 (node/22.0.0)" \
-H "Origin: https://example.keenauth.app" \
-H "Idp-Csrf-Token: $CSRF_TOKEN" \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{"identifier":"jane@example.com"}'
Personal data
This endpoint carries personal data: end-user profile and credentials. Personal data in the KeenAuth APIs describes what is stored, how long it is kept and how it is deleted. Do not send special categories of personal data (health, biometric data, religion, political opinions, sexual orientation) in any field.