Auth: Wiring Ory
Kilter's auth stack is Ory: Kratos (identity and sessions), Oathkeeper (reverse proxy that authenticates requests), and optionally Keto (relationship-based permissions). Scaffold everything with:
kilter run add-auth # adds the ory service + proxy, auth lib, pages, middleware
npm install @ory/client
kilter upThree patterns make it work in any framework. Get these right and the rest is form rendering.
1. The same-origin proxy (non-negotiable)
Kratos uses cookies for sessions, and browsers refuse cross-origin cookies. So the browser must never talk to Kratos directly — all traffic goes through your app at /api/ory/*, which forwards to Kratos server-side.
No error, no redirect — the browser just drops the Set-Cookie. If cookies never appear, this is the first thing to check.
The proxy is a catch-all route handler that must do three things:
// 1. Forward the request to Kratos, redirect: 'manual'
const res = await fetch(`${KRATOS_URL}${path}${search}`, { ...passthrough });
// 2. Strip Domain= from Set-Cookie so cookies bind to your origin
cookie.replace(/Domain=[^;]+;?\s*/gi, '')
// 3. Rewrite Location headers (and ui.action in JSON bodies) back to /api/ory
location.replace(/https?:\/\/ory-kratos(?::\d+)?/g, '/api/ory')Match the in-cluster hostname pattern (ory-kratos:<port>), not just the exact KRATOS_URL value — if the Kratos chart's serve.public.base_url is misconfigured, flow responses leak the in-cluster hostname into form actions, and the browser fails with ERR_NAME_NOT_RESOLVED. Rewriting defensively at the proxy catches that.
Verify: visit /api/ory/self-service/login/browser — you should land on /login?flow=<uuid> with an ory_kratos_session cookie visible in dev tools.
2. Session resolution: headers in prod, toSession in dev
Write one getUser() chokepoint and call it everywhere:
- Production: Oathkeeper sits in front of the app, validates the session cookie against Kratos, and injects
x-user-id/x-user-emailheaders. Read those — no Kratos round-trip. - Dev fallback: no Oathkeeper in the path, so forward the browser's
Cookieheader to KratostoSession()and read the identity from the response.
export async function getUser() {
const userId = requestHeaders.get('x-user-id'); // prod: Oathkeeper
const email = requestHeaders.get('x-user-email');
if (userId && email) return ensureUser(userId, email);
const cookie = requestHeaders.get('cookie'); // dev: direct Kratos
if (!cookie) return null;
const { data: session } = await ory.toSession({ cookie });
const role = session.identity.metadata_public?.role;
return ensureUser(session.identity.id, session.identity.traits.email, role);
}Passing cookie explicitly on every server-side Kratos call matters — omit it and Kratos treats the request as a fresh anonymous browser (see Auth: Flows & Recovery).
3. JIT provisioning and role sync
Users are created in your app database on first authenticated request, not at registration. ensureUser() upserts a row keyed on kratos_identity_id and syncs the role:
- Roles originate in Kratos
metadata_public.role(set via the admin API or seed job). - On each login,
ensureUser()copies that role into yourprofiles.rolecolumn if it differs. - App code checks
profiles.role— the app DB is the source of truth, not Kratos metadata and not Keto.
To grant admin: set metadata_public.role = "admin" on the Kratos identity (syncs on next login), or update profiles.role directly.
Route protection
Oathkeeper rules declare which routes need auth. Key points: exclude /api/ory/* (that's the proxy itself), use the cookie_session authenticator, and the header mutator to inject X-User-Id. In dev, lightweight middleware checking for the ory_kratos_session cookie covers redirects — but in production behind Oathkeeper, middleware must check the x-user-id header instead, or you'll build a redirect loop (see Auth: Troubleshooting).