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
- Browser hits
/api/ory/self-service/login/browser(through the proxy). - Kratos creates a flow, sets a CSRF cookie, redirects to
/login?flow=<id>. - Your server fetches the flow (
getLoginFlow) and renders a form fromflow.ui.nodes— including the hiddencsrf_tokennode — posting toflow.ui.action. - 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.
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 cookie-forwarding trap (307 loop)
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
| Status | Error id | Meaning | Handle |
|---|---|---|---|
| 400 | session_already_available | Already logged in | Redirect to the post-login path |
| 410 | self_service_flow_expired | Flow TTL passed (1h) or consumed | Start a new flow; use_flow_id in the body lets you skip re-init |
| 422 | browser_location_change_required | Kratos needs a browser navigation | window.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:
- Init recovery flow → form asks for email.
- Submit
method: "code", email→ Kratos emails a 6-digit numeric code (not a link). - Submit
method: "code", code→ Kratos returns 422 — this is success, not an error. The response sets a privilegedory_kratos_sessioncookie, anderror.reasoncontains the settings-flow URL; regex the flow id out (/[?&]flow=([a-f0-9-]+)/). - 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.