Store Custom User Traits in Ory Kratos Without Database Migrations
Use Ory Kratos JSON Schema identity traits to add custom user fields without migrations, with server-side validation and flow-driven UI rendering.
03 Oct 2026, 14:25 UTC

The migration tax on user profiles
Most applications start with a fixed users table: email, password hash, maybe a name. When marketing asks for a department field or support needs a Slack handle, the usual path is a database migration, a model change, an API endpoint, and a frontend form. If the field becomes optional later, you migrate again.
Ory Kratos treats identity data as a JSON document validated against a JSON Schema you control. New traits can be added by updating the schema, not by altering tables. Kratos stores traits as-is and returns them through the session and identity APIs.
How traits, schemas, and UI nodes fit together
Kratos validates every identity against a JSON Schema (Draft 7 or 2019-09). The schema lives under a traits property and can use Ory-specific keywords such as ory.sh/kratos/credentials to tie fields to password, OIDC, or TOTP verification.
Self-service flows are state machines identified by a flow ID. When a frontend asks for a registration or settings flow, Kratos returns ui.nodes: an array describing each form field, its type, attributes, validation rules, and any error messages. The backend is the single source of truth for what the form should render, so adding a schema field automatically changes the flow data without touching frontend route code.
Worked example: add department and Slack handle
Assume you run self-hosted Ory Kratos (OSS) and can edit its configuration. First inspect the schema Kratos currently uses:
# Run in a terminal with Docker access. Pulls the image if missing.
docker run --rm oryd/kratos:latest version
Then fetch the registration flow to see the schema the frontend receives:
# Replace KRATOS_PUBLIC_URL with your Kratos public endpoint, e.g. http://127.0.0.1:4455
# Requires jq and network access to the Kratos public endpoint. Read-only.
curl -s "$KRATOS_PUBLIC_URL/selfservice/registration/browser?format=json" | jq '.ui.schema'
Create a schema file, for example identity.schema.json, that adds two optional string traits:
{
"$id": "https://example.com/identity.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": { "type": "string", "format": "email" },
"department": { "type": "string" },
"slack_handle": { "type": "string" }
},
"additionalProperties": false
}
}
}
Add this file to Kratos's identity schema list in your configuration with an id and url, then restart Kratos so it loads the file. Because the schema changes the stored identity shape, rollback means restoring the previous schema file and restarting the container.
To verify validation, start a registration flow, capture its ID, and submit a trait with the wrong type:
# Replace FLOW_ID with an active registration flow ID from the flow response.
# Replace KRATOS_PUBLIC_URL with your Kratos public endpoint.
curl -s -X POST "$KRATOS_PUBLIC_URL/selfservice/registration?flow=$FLOW_ID" \
-H 'Content-Type: application/json' \
-d '{"traits":{"email":"[contact removed]","password":"secret123","department":12345}}'
Expected check: the response should be HTTP 400, and the body should include ui.messages describing a type error for department. The corresponding entry in ui.nodes should carry that message so a custom frontend can render it inline. An invalid submission does not create an identity.
Limits to plan around
additionalPropertiesdefaults to false in Kratos-generated schemas. Adding a new optional trait requires a schema update, and existing identities must still validate against the revised schema.- Uniqueness constraints such as unique email are enforced only on verified addresses. Unverified duplicates can exist temporarily.
- Flows expire after one hour by default. Multi-step onboarding that leaves a tab open too long requires the user to restart the flow.
- Ory Network may lag self-hosted releases, so newer schema extensions might not be available immediately in the managed offering.
Check before you ship
Submit one valid registration with the new traits, then call the session or identity API and confirm the traits appear exactly as submitted. Submit one invalid registration and confirm ui.messages maps to the right ui.nodes field. If both behave as expected, your schema is the source of truth and you can add future fields without migrations.
Start with the built-in UI to validate the flow contracts, then replace the frontend with your own SPA or server-rendered app by consuming the same ui.nodes payload. The backend contract does not change when you move from built-in UI to headless.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.