KBkilterKB
dev

Auth: Flows & Recovery

Kratos never accepts credentials directly. Every interaction — login, registration, recovery, settings — is a flow: initialize it, render the form it describes, submit to it.

Flow lifecycle

  1. Browser hits /api/ory/self-service/login/browser (through the proxy).
  2. Kratos creates a flow, sets a CSRF cookie, redirects to /login?flow=<id>.
  3. Your server fetches the flow (getLoginFlow) and renders a form from flow.ui.nodes — including the hidden csrf_token node — posting to flow.ui.action.
  4. Browser submits with credentials: 'include'. Kratos returns 200 (success) or 400 (validation errors).

Registration and settings follow the same shape; registration submits traits.email, traits.name.first, etc. instead of identifier.

Flows are single-use

Once submitted — even successfully — a flow is consumed. A second POST returns 400 with no useful message, which looks exactly like "wrong password". Guard the form against double-submit (disable the button, gate on a status flag) and skip flow init when the user already has a session.

The most common flow bug: the page redirects forever, and the ?flow= UUID changes on every redirect.

Cause: your server fetches the flow without forwarding the browser's Cookie header. Kratos can't see the CSRF/flow-bound cookies, treats each request as a new anonymous browser, and mints a fresh flow every time.

Fix: pass cookie on every server-side Kratos call — toSession and all flow fetches (getLoginFlow, getRegistrationFlow, getRecoveryFlow, getSettingsFlow):

const cookie = incomingRequest.headers.get('cookie') ?? undefined;
const { data: flow } = await ory.getLoginFlow({ id: flowId, cookie });

If the UUID doesn't rotate between redirects, you have a different problem — a post-login middleware loop; see Auth: Troubleshooting.

Error responses worth handling

StatusError idMeaningHandle
400session_already_availableAlready logged inRedirect to the post-login path
410self_service_flow_expiredFlow TTL passed (1h) or consumedStart a new flow; use_flow_id in the body lets you skip re-init
422browser_location_change_requiredKratos needs a browser navigationwindow.location.href = data.redirect_browser_to (may be external — don't use client-side routing)

Recovery (email + 6-digit code)

Recovery is a two-step form feeding into a settings flow:

  1. Init recovery flow → form asks for email.
  2. Submit method: "code", email → Kratos emails a 6-digit numeric code (not a link).
  3. Submit method: "code", code → Kratos returns 422 — this is success, not an error. The response sets a privileged ory_kratos_session cookie, and error.reason contains the settings-flow URL; regex the flow id out (/[?&]flow=([a-f0-9-]+)/).
  4. Navigate to the settings page and submit method: "password" with the new password.

Two sharp edges: handle the 422 before any generic error check, and keep credentials: 'include' on the code-verification POST — without it the browser drops the privileged session cookie and the settings flow 401s.

In dev, recovery emails land in Mailpit — get its UI URL from kilter env and read the code from the inbox. If nothing arrives, check kilter logs kratos for courier errors.

Link to recovery from the login page (/auth/recovery) — don't inline the recovery flow into the login form; that sends the email with nowhere to enter the code.

Logout

Handle logout entirely server-side: fetch /self-service/logout/browser with the session cookie to get logout_url, call it to revoke the session, then delete the cookie and redirect to /login. Cookie deletion + redirect is the primary mechanism; the Kratos revocation is best-effort.