Skip to content
Stribog

Identity

All writing

Ory Kratos: Headless Identity and the Flows You Now Own

Ory Kratos serves login, registration and recovery as APIs; your app renders every screen. What that moves onto you, and why Kratos is not an OIDC provider.

Stribog15 min read

Most write-ups file Kratos next to Keycloak, Authentik and Zitadel as one more self-hosted login option. That is the wrong shelf. Those products are identity providers: users log in to them, then get sent back to your application. Kratos never shows anyone a page. This article maps what that means in practice as of 23 October 2026, against open-source tag v26.2.0 — the flows, the identity schema, the obligations you inherit, a production checklist with the reason for every line, and the exit ramps in both directions.

Kratos is a lifecycle engine, not an identity provider

The ory/kratos README describes it as "an API first identity and user management system" that "centralizes login, registration, recovery, verification, and profile management flows so your services consume them instead of reimplementing them." Ory's projects overview fills in the rest: Kratos manages identities, credentials and sessions, powers self-service flows including multi-factor authentication, supports passwords, social sign-in, OpenID Connect and passkeys, and models users with customizable JSON Schema.

What it does not do is issue OAuth2 or OIDC tokens to other applications. In the Ory stack that job belongs to Ory Hydra, which the same overview calls "a fully featured, OpenID Certified® OAuth 2.0 and OpenID Connect provider." The Kratos README is blunt about the consequence: teams leaving Auth0 or Okta with OAuth2/OIDC-based login should "consider using Ory Hydra + Ory Kratos together," with Hydra acting as the provider.

Keycloak sits at the other end of the line. Its homepage puts it plainly: "Users authenticate with Keycloak rather than individual applications," so applications do not deal with login forms or store users, and Keycloak can broker to existing OIDC or SAML 2.0 providers. Keycloak 26.8.1 shipped on 13 October 2026; it is an actively maintained product that owns the login page by design.

So the comparison is not a feature duel. It is a layering decision, and the Keycloak, Authentik and Zitadel comparison covers the provider side of it. Pick a provider when many applications should redirect to one login. Pick Kratos when the login, the onboarding and the recovery screens are part of your product.

With Kratos the login page is yours and the engine sits behind it; with an identity provider the login page lives inside the provider.

How a headless flow actually runs

Every self-service action is a flow object with an ID, an expiry and a list of UI nodes. Ory's self-service flow documentation describes a browser login: the browser calls /self-service/login/browser, Kratos stores a new flow and answers HTTP 303 to your configured selfservice.flows.login.ui_url, appending the flow ID as the flow query parameter. Your login route reads that parameter, fetches the flow from /self-service/login/flows?id=… with the browser's cookies, and renders whatever nodes Kratos returned — inputs, hidden CSRF token, messages, validation errors. The form posts to the flow's ui.action.

Kratos never renders the form. It holds the flow state and the session; your UI does everything a user sees.

You can trace the whole thing with curl and a cookie jar, which is the fastest way to see what your UI must reproduce:

bash
#!/usr/bin/env bash
set -euo pipefail
KRATOS=https://auth.example.com
JAR=$(mktemp)

# 1. Initialise: observe the 303 to ui_url?flow=<id> and the CSRF cookie
curl -s -o /dev/null -D - -c "$JAR" "$KRATOS/self-service/login/browser" \
  | grep -i '^location:'

# 2. A JSON client gets the flow body instead of the redirect
FLOW=$(curl -s -b "$JAR" -c "$JAR" -H 'Accept: application/json' \
  "$KRATOS/self-service/login/browser")
FLOW_ID=$(jq -r '.id' <<<"$FLOW")
CSRF=$(jq -r '.ui.nodes[] | select(.attributes.name=="csrf_token") | .attributes.value' <<<"$FLOW")

# 3. What your UI fetches to render the form
curl -s -b "$JAR" -H 'Accept: application/json' \
  "$KRATOS/self-service/login/flows?id=$FLOW_ID" | jq '.ui.nodes | length'

# 4. Submit with cookies and the CSRF token, then read the session
curl -s -b "$JAR" -c "$JAR" -X POST \
  -H 'Content-Type: application/json' -H 'Accept: application/json' \
  -d "$(jq -n --arg c "$CSRF" '{method:"password", csrf_token:$c,
        identifier:"user@example.com", password:env.TEST_PASSWORD}')" \
  "$KRATOS/self-service/login?flow=$FLOW_ID" >/dev/null
curl -s -b "$JAR" "$KRATOS/sessions/whoami" | jq '.authenticator_assurance_level'
Tracing a browser login against a test identity. TEST_PASSWORD comes from the environment.

Kratos supports two flow types: browser flows, and API flows for clients such as mobile apps or smart TVs. Ory's warning about mixing them is unusually emphatic — "Never use API flows to implement Browser applications!" — because API flows in single-page or server-side apps open login CSRF and related attacks. Per the session documentation, browsers receive an Ory Session Cookie (ory_kratos_session, the default name in the v26.2.0 config schema), other clients an Ory Session Token, and both resolve through /sessions/whoami.

The cookie model imposes one hard topology rule: Kratos and your UI must share a top-level domain. Ory's own examples: ory.bar.com with app.bar.com works, ory.bar.com with bar.com works, ory.bar.com with not-bar.com does not. Decide the hostnames before writing UI code.

Sharing a domain is necessary, not sufficient. That rule is about the browser talking to Kratos; it says nothing about which host receives the session cookie afterwards. Kratos sets a cookie domain only when session.cookie.domain or cookies.domain is configured — the former overrides the latter, and neither has a default in v26.2.0. Leave both unset and ory_kratos_session is a host-only cookie for auth.example.com: the browser sends it back to Kratos and never to app.example.com. For the two-host layout used throughout this article, set cookies.domain: example.com, as the production excerpt below does, and the browser attaches the cookie to requests for your backend too. Your backend then forwards that Cookie header to /sessions/whoami server-to-server and trusts only what Kratos returns. The alternative is to leave the cookie host-only and have the browser call /sessions/whoami on the Kratos host directly, which suits a single-page app but leaves your backend with nothing to verify. Ory's schema describes the domain setting with "Use with care!" for good reason: a parent-domain cookie reaches every subdomain under it, so any host there that logs headers or runs third-party code can see sessions.

The login methods on offer are password, oidc for social sign-in, passkey, and code by email or SMS; webauthn remains as a legacy method, with passkeys recommended instead.

The identity schema is your user model — and your login policy

A Kratos identity is a set of traits validated against an identity schema that implements JSON Schema. The schema does more than validate. Through the ory.sh/kratos extension it decides which field is the login identifier and which fields carry verification and recovery — so it is a policy document as much as a data model.

json
{
  "$id": "https://schemas.example.com/customer.schema.json",
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Customer",
  "type": "object",
  "properties": {
    "traits": {
      "type": "object",
      "properties": {
        "email": {
          "type": "string",
          "format": "email",
          "title": "Email",
          "maxLength": 320,
          "ory.sh/kratos": {
            "credentials": {
              "password": { "identifier": true },
              "code": { "identifier": true, "via": "email" }
            },
            "verification": { "via": "email" },
            "recovery": { "via": "email" }
          }
        },
        "name": {
          "type": "object",
          "properties": {
            "first": { "type": "string", "title": "First name", "maxLength": 100 },
            "last": { "type": "string", "title": "Last name", "maxLength": 100 }
          },
          "additionalProperties": false
        }
      },
      "required": ["email"],
      "additionalProperties": false
    }
  }
}
A complete identity schema: email is the password and one-time-code identifier and the verification and recovery address.

The customization guide lists the extension keys: credentials.password.identifier, credentials.code.identifier with via, credentials.passkey.display_name, credentials.totp.account_name, and recovery.via / verification.via. Two consequences follow. Changing which trait is the identifier changes who can log in with what, so treat a schema edit as a migration with review, not a config tweak. And additionalProperties: false is the line that stops a client from writing arbitrary keys into a user record.

Ory also notes you can define different schemas for different groups of users — customer support staff and end users, say — each with its own traits and identifiers. That is useful, and it multiplies the number of forms your UI renders and the number of documents you version.

What a hosted IdP did that you now own

With an identity provider, a vendor or the provider's project carries a long list of things your team never thinks about. Going headless moves that list into your backlog:

  • Every screen. Login, registration, recovery, verification, settings, MFA enrolment and the error page, in every state and language. Ory's reference UI implements all the flows in NodeJS, ExpressJS, Handlebars and NextJS — a working specification to read, not a product to ship unchanged.
  • Email delivery. Kratos queues messages; a courier worker sends them. Templates need both plain-text and HTML versions, and a message that fails message_retries times (default 5) is marked Abandoned and never retried. Alert on that status.
  • The recovery method. One-time code is the default and the method Ory recommends; magic links are considered legacy and slated for removal. Do not build new UI around links.
  • Password policy. In the v26.2.0 config schema, minimum length defaults to 8 (6 is the lowest allowed), and both the Have I Been Pwned check and the password-to-identifier similarity check default to on. The breach check uses the range API, so the password itself never leaves the host — but it is an outbound call your egress rules must allow or you must switch off deliberately.
  • Hashing. BCrypt by default at cost 12, Argon2id configurable. BCrypt rejects passwords over 72 bytes with an error, so your UI must show that message rather than a generic failure.

Business logic — provisioning a tenant row, notifying a CRM, blocking a domain — goes through hooks. Be careful which documentation you read. The open-source v26.2.0 executor implements six hook names: revoke_active_sessions, session, require_verified_address, verification, show_verification_ui and web_hook, and each flow's schema accepts only a subset. web_hook is the one that calls your code. The same schema also lists b2b_sso and organization, but the open-source binary does not implement them: it logs "A configuration for a non-existing hook was found and will be ignored" and carries on, so a schema-valid config can still be a silent no-op. Ory's configure-hooks page instead lists "6 actions" under Ory Actions, which it introduces as an Ory Network mechanism; two of them, verify_new_address and notify_previous_addresses, do not appear in the v26.2.0 schema. Configure from the executor of the tag you run, not from the Network action list, and check the startup log for that warning.

Running it in production: the checklist with reasons

Ory's production guide is short. Each line exists for a reason worth knowing:

  1. A real database. PostgreSQL, MySQL or CockroachDB. "Don't use SQLite in production!"
  2. No --dev. The flag exists for local work; production omits it from kratos serve.
  3. Four explicit secrets. Set custom default, cookie, pagination and cipher secrets. openssl rand -base64 32 suits the first three, but not cipher: the v26.2.0 schema accepts a cipher secret only at exactly 32 characters, and that command prints 44. A wrong-length cipher value fails schema validation at startup, so generate it with openssl rand -base64 24, which prints exactly 32. The pagination secret is not cosmetic either: see below.
  4. The Admin API stays private. "Never expose the Ory Kratos Admin API to the internet unsecured." Ory's advice is not to expose it publicly at all and to front it with a zero-trust network inside.
  5. Outbound HTTP guard. clients.http.disallow_private_ip_ranges: true makes every outbound call — web hooks included — fail against private address ranges, which blunts server-side request forgery through a misconfigured hook URL.
  6. Upgrades are migrations. The upgrade guide: back up, read CHANGELOG.md for breaking changes, update the SDK, install the new version, run kratos migrate sql. A release that introduces phone-number normalization also needs kratos migrate normalize-phone-numbers once the new version is serving.
yaml
# excerpt — kratos.yml for production; secrets shown as <PLACEHOLDER>
# DSN comes from the environment variable DSN, not this file.
serve:
  public:
    base_url: https://auth.example.com/
  admin:
    base_url: http://kratos-admin.internal:4434/ # internal network only
secrets:
  default: [<PLACEHOLDER_DEFAULT_SECRET>]
  cookie: [<PLACEHOLDER_COOKIE_SECRET>]
  cipher: [<PLACEHOLDER_32_CHARACTER_CIPHER_SECRET>] # exactly 32 characters
  pagination: [<PLACEHOLDER_PAGINATION_SECRET>] # never leave unset
cookies:
  domain: example.com # shared parent of auth. and app.; unset = host-only on auth.
identity:
  default_schema_id: customer
  schemas:
    - id: customer
      url: file:///etc/kratos/customer.schema.json
selfservice:
  default_browser_return_url: https://app.example.com/
  methods:
    password:
      enabled: true
      config:
        min_password_length: 12
        haveibeenpwned_enabled: true
  flows:
    login:
      ui_url: https://app.example.com/login
    recovery:
      enabled: true
      use: code
      ui_url: https://app.example.com/recovery
hashers:
  algorithm: bcrypt
  bcrypt:
    cost: 12
clients:
  http:
    disallow_private_ip_ranges: true
Excerpt only: secrets are placeholders and most flow and courier settings are omitted.

The pagination secret is the clearest case for reading the checklist literally. CVE-2026-33503 (GHSA-hgx2-28f8-6g2r, published 2026-03-20, high, CVSS 7.2) is an SQL injection in the ListCourierMessages Admin API through forged pagination tokens. When secrets.pagination is unset, Kratos falls back to a default that is publicly known, so an attacker can mint valid malicious tokens. The vector requires high privileges — it is an Admin API bug, not an unauthenticated one — which is one more reason that API never faces the internet. The advisory record's own metadata names v25.4.0 as vulnerable and v26.2.0 as patched; NVD describes every version prior to 26.2.0. The instruction both support: set a custom secrets.pagination now, and run 26.2.0 or later.

Scaling has one trap. The production guide says there are "no additional requirements for scaling Ory Kratos, just spin up another container!" — true for kratos serve, and false for the courier. The courier page says it "must run as a singleton": --watch-courier on a single instance, or a separate kratos courier watch job when Kratos is replicated. --watch-courier defaults to off, so plain replicated kratos serve sends no mail at all; the failure is starting every replica with --watch-courier, which turns each one into a courier and duplicates delivery.

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: kratos
spec:
  replicas: 3
  selector:
    matchLabels: { app: kratos }
  template:
    metadata:
      labels: { app: kratos }
    spec:
      containers:
        - name: kratos
          image: oryd/kratos:v26.2.0
          args: ["serve", "-c", "/etc/kratos/kratos.yml"] # no --watch-courier, no --dev
          envFrom: [{ secretRef: { name: kratos-env } }]
          ports: [{ containerPort: 4433 }, { containerPort: 4434 }]
          volumeMounts: [{ name: config, mountPath: /etc/kratos, readOnly: true }]
      volumes: [{ name: config, configMap: { name: kratos-config } }]
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: kratos-courier
spec:
  replicas: 1
  strategy: { type: Recreate } # never two couriers during a rollout
  selector:
    matchLabels: { app: kratos-courier }
  template:
    metadata:
      labels: { app: kratos-courier }
    spec:
      containers:
        - name: courier
          image: oryd/kratos:v26.2.0
          args: ["courier", "watch", "-c", "/etc/kratos/kratos.yml"]
          envFrom: [{ secretRef: { name: kratos-env } }]
          volumeMounts: [{ name: config, mountPath: /etc/kratos, readOnly: true }]
      volumes: [{ name: config, configMap: { name: kratos-config } }]
Replicated serve, singleton courier. Recreate stops the old courier pod before the new one starts.

The upgrade itself, with the flags as they exist in cmd/migrate/sql.go at v26.2.0 — -e reads the DSN from the DSN environment variable or the config key, --yes skips the confirmation prompt:

bash
#!/usr/bin/env bash
set -euo pipefail
# Kratos DSN: may carry pool keys (max_conns, max_idle_conns) libpq rejects.
: "${DSN:?export DSN=postgres://... first}"
# Plain libpq URI for the backup: same host and database, libpq keys only.
: "${PG_BACKUP_URI:?export PG_BACKUP_URI=postgres://user:pass@host:5432/kratos?sslmode=require first}"

pg_dump --format=custom --file="kratos-$(date +%F).dump" --dbname="$PG_BACKUP_URI"  # 1. back up
kratos migrate sql -e --yes -c /etc/kratos/kratos.yml              # 2. migrate
kratos serve -c /etc/kratos/kratos.yml &                            # 3. serve (no --dev)
kratos courier watch -c /etc/kratos/kratos.yml                      # 4. exactly one courier
Single-host sequence for PostgreSQL. Do not hand pg_dump the Kratos DSN: libpq stops on an unknown URI query parameter, so strip `max_conns`, `max_idle_conns`, `max_conn_lifetime` and any other non-libpq key, keep only libpq keys such as `sslmode`, and never skip the backup to get past the error. On MySQL or CockroachDB substitute that database's backup tool. On Kubernetes, run step 2 as a Job before rolling the Deployments.

Security and audit: what you can show an auditor

Headless does not mean evidence-free. Each session records authenticator_assurance_level and an authentication_methods list with the method, its AAL and when it completed, so "was MFA used for this action" is a field you can log and check, not an inference. Log both from the /sessions/whoami response at the moment a sensitive action is authorised — each authentication_methods entry carries method, aal and completed_at — and the record shows how the user proved who they were, not merely that a session existed.

The configuration is the other half of the evidence. The controls an auditor asks about map to specific keys: hashers.algorithm and hashers.bcrypt.cost for credential storage, selfservice.methods.password.config.min_password_length and haveibeenpwned_enabled for password policy, secrets.* for key management, cookies.domain for session scope, and clients.http.disallow_private_ip_ranges for outbound restriction. All of them live in kratos.yml — keep it in Git and the change history is the control evidence, with every weakening of a default visible as a reviewed diff rather than a console click nobody recorded. Network isolation of the Admin API is a diagram plus a firewall or NetworkPolicy rule, both reviewable. The audit-log pipeline is where those records should land.

Two things an auditor will ask that the software cannot answer for you. First, patch cadence: Ory's projects table lists CVE patching as "Self-managed" for open source, "Guaranteed timeframes" under the Ory Enterprise License, and "Handled by Ory" on Ory Network. On the open-source path, advisory tracking is your control — CVE-2026-33503 is the worked example. Second, data responsibility: traits such as email and name are personal data in your database. Kratos does not decide your role under data-protection law; your processing agreements and records do.

Exit ramps: getting identities in, and back out

Migration in is unusually well specified. PATCH /admin/identities in the v26.2.0 OpenAPI spec creates identities in bulk — up to 1,000 per request when passwords are hashed, up to 200 if any is plaintext — and Ory's import guide asks you to hash before importing more than 20 clear-text passwords to avoid timeouts. Supported hash formats are BCrypt, Argon2, MD5, SHA, SSHA/SSHA256/SSHA512, PBKDF2, SCrypt, Firebase SCrypt, crypt(3) and HMAC. For hashes you cannot export, import an empty hash with use_password_migration_hook: true; Kratos defers to a password-migration web hook at first login.

json
{
  "identities": [
    {
      "patch_id": "6f1c1f2e-0a4b-4e0e-9a51-1d7c3c2b9a01",
      "create": {
        "schema_id": "customer",
        "traits": { "email": "ada@example.com", "name": { "first": "Ada", "last": "Example" } },
        "credentials": {
          "password": {
            "config": { "hashed_password": "$2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW" }
          }
        }
      }
    },
    {
      "patch_id": "0b7d9e34-5c21-4f6a-8e2d-93a1f0c4d5b2",
      "create": {
        "schema_id": "customer",
        "traits": { "email": "grace@example.com" },
        "credentials": {
          "password": {
            "config": { "hashed_password": "", "use_password_migration_hook": true }
          }
        }
      }
    }
  ]
}
Bulk import body: one BCrypt hash carried over, one account deferred to the password-migration hook.

Migration out uses the same API. The admin identity endpoints accept include_credential; with password, the response carries the stored password hash — never a plaintext password — and with oidc it returns the initial access, refresh and ID tokens where available. A hash export is exactly what the next system's import step starts from. And the UI you built is yours: if the backend changes, the screens, copy and accessibility work stay.

The licence boundary is the other half of optionality. The repository is Apache 2.0, but Ory states that the open-source version "is intended for single-tenant use only" and that its data model is not built for multi-tenant isolation; multi-tenancy means Ory Network or the Ory Enterprise License. A B2B product planning one Kratos for many customer organisations should know that before the schema is designed — the same kind of gate the OpenBao and Vault migration turns on.

When to choose Kratos, and when not to

  • Choose Kratos when sign-up, login and recovery are product surfaces — consumer apps, native clients, onboarding funnels you design and test — and you will staff the flows, the courier and the upgrades as ongoing work.
  • Choose an identity provider when the need is single sign-on for many applications over OIDC or SAML, with a login page and admin console that work on day one. For a gateway in front of internal tools, see Authelia and Authentik.
  • Pair Kratos with Hydra when you want your own login UX and must also issue OAuth2/OIDC tokens to other applications — exactly what Ory's README recommends for teams leaving Auth0 or Okta.
  • Keep secrets out of the repository either way. The four Kratos secrets belong in your secrets pipeline, rotated by putting the new value first in each list — the first entry signs or encrypts, older entries only verify or decrypt.

The long-game view is the honest one. With Kratos, the identity flows become code your team maintains for as long as the product lives: a schema with a version history, screens with accessibility obligations, a courier with an alert, a migration step in every upgrade. That is more work than a redirect. It is also an identity layer whose data, formats and interface you own outright — and that is the point of choosing it.

§FAQ/Common questions

Frequently asked

Is Ory Kratos an OAuth2 or OpenID Connect provider?

No. Kratos manages identities, credentials, sessions and self-service flows such as login, registration and recovery. Issuing OAuth2 and OIDC tokens to other applications is Ory Hydra's job, and Ory's README advises teams migrating from Auth0 or Okta to use Hydra and Kratos together.

What is the difference between Kratos browser flows and API flows?

Browser flows are for users in a web browser and rely on cookies and a CSRF token; API flows are for native clients such as mobile apps and return a session token. Ory warns never to use API flows for browser applications, because doing so opens login CSRF and related attacks.

Why does the Kratos courier need to run as a singleton?

The courier processes the email queue and Ory's documentation says it must run as a singleton. Use --watch-courier on a single kratos serve instance, or run one separate kratos courier watch process when Kratos itself is replicated, so every replica does not send mail.

Can I migrate existing password hashes into Ory Kratos?

Yes. PATCH /admin/identities imports up to 1,000 identities per request with hashed passwords, or 200 if any is plaintext. Kratos accepts BCrypt, Argon2, PBKDF2, SCrypt, Firebase SCrypt, crypt(3), HMAC and other formats, and a password-migration web hook covers hashes you cannot export.

Does open-source Ory Kratos support multi-tenancy?

No. Ory states the open-source version is intended for single-tenant use and its data model is not built for multi-tenant isolation. Multi-tenancy requires Ory Network or the self-hosted Ory Enterprise License.

ory kratosory kratos self-hosted productionkratos identity schema json schemakratos browser flow vs api flowkratos mail courier singletonory kratos vs keycloak

Executive Briefing

Thirty minutes to clarify your infrastructure risk

Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.