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.

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:

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.