Expose a SCIM endpoint
Let enterprise IdPs provision users via SCIM 2.0
SCIM (System for Cross-domain Identity Management) is the standard for "the IdP sends you user data, you create/update/delete." Enterprise customers expect this.
Olympus doesn't ship a SCIM endpoint by default, Kratos isn't a SCIM server. You build one as a thin proxy in front of Kratos's admin API.
Endpoint shape
SCIM 2.0 defines:
GET /scim/v2/Users # list
GET /scim/v2/Users/{id} # read
POST /scim/v2/Users # create
PUT /scim/v2/Users/{id} # replace
PATCH /scim/v2/Users/{id} # update
DELETE /scim/v2/Users/{id} # removePlus /Groups for group management (optional).
Auth: Bearer token per tenant
Each enterprise customer gets a unique SCIM token they configure in their IdP.
CREATE TABLE scim_tokens (
token_hash TEXT PRIMARY KEY,
tenant_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ
);Issue tokens to customers via Athena's admin UI.
Implementation (Node / Hono)
import { Hono } from "hono";
import { kratos } from "./kratos-client";
const scim = new Hono();
scim.use("*", async (c, next) => {
const token = c.req.header("authorization")?.slice(7);
if (!token) return c.json({ status: 401, detail: "missing_token" }, 401);
const hash = sha256(token);
const row = await db`SELECT tenant_id FROM scim_tokens WHERE token_hash = ${hash}`.first();
if (!row) return c.json({ status: 401, detail: "invalid_token" }, 401);
c.set("tenantId", row.tenant_id);
await next();
});
// LIST
scim.get("/Users", async (c) => {
const tenantId = c.get("tenantId");
const identities = await kratos.adminList({
filter: `traits.tenant_id eq "${tenantId}"`,
per_page: c.req.query("count") ?? "100",
});
return c.json({
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
totalResults: identities.length,
Resources: identities.map(toScimUser),
});
});
// CREATE
scim.post("/Users", async (c) => {
const tenantId = c.get("tenantId");
const body = await c.req.json();
const identity = await kratos.adminCreate({
schema_id: "default",
traits: {
tenant_id: tenantId,
email: body.emails[0].value,
first_name: body.name?.givenName,
last_name: body.name?.familyName,
external_id: body.externalId,
},
state: body.active === false ? "inactive" : "active",
});
return c.json(toScimUser(identity), 201);
});
// PATCH (partial update)
scim.patch("/Users/:id", async (c) => {
const tenantId = c.get("tenantId");
const id = c.req.param("id");
const { Operations } = await c.req.json();
// Verify tenant ownership
const identity = await kratos.adminGet(id);
if (identity.traits.tenant_id !== tenantId) return c.json({ status: 404 }, 404);
const patches = [];
for (const op of Operations) {
if (op.path === "active") {
patches.push({ op: "replace", path: "/state", value: op.value ? "active" : "inactive" });
}
// etc. for other attributes
}
const updated = await kratos.adminPatch(id, patches);
return c.json(toScimUser(updated));
});
// DELETE
scim.delete("/Users/:id", async (c) => {
const tenantId = c.get("tenantId");
const id = c.req.param("id");
const identity = await kratos.adminGet(id);
if (identity.traits.tenant_id !== tenantId) return c.json({ status: 404 }, 404);
await kratos.adminDelete(id);
return c.body(null, 204);
});
function toScimUser(identity) {
return {
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
id: identity.id,
externalId: identity.traits.external_id,
userName: identity.traits.email,
name: {
givenName: identity.traits.first_name,
familyName: identity.traits.last_name,
},
emails: [{ value: identity.traits.email, primary: true }],
active: identity.state === "active",
meta: {
resourceType: "User",
created: identity.created_at,
lastModified: identity.updated_at,
},
};
}Service Provider Configuration
scim.get("/ServiceProviderConfig", (c) => c.json({
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
documentationUri: "https://your-domain.com/docs/scim",
patch: { supported: true },
bulk: { supported: false },
filter: { supported: true, maxResults: 200 },
changePassword: { supported: false },
sort: { supported: false },
etag: { supported: false },
authenticationSchemes: [{
type: "oauthbearertoken",
name: "OAuth Bearer Token",
description: "Bearer token issued by [Your App]",
}],
}));Testing with Okta
- Customer adds your SCIM endpoint URL:
https://your-app.com/scim/v2. - Bearer token: from Athena's "SCIM tokens" page.
- Run "Test API Credentials", should succeed.
- Assign Okta users → they appear in Kratos.
- Unassign → state goes inactive.
Group support
For role assignments via groups, implement /Groups similarly. Maps SCIM group name to a role trait.
Rate limiting
SCIM endpoints will see bursty traffic when customers onboard 1000+ users. Rate-limit:
- 100 req/min per token.
- 10 concurrent at a time.
Audit log
Every SCIM operation should be logged:
INSERT INTO security_audit (event_type, actor, target, metadata)
VALUES ('scim_user_create', $token_hash, $identity_id, {...});So customers can see "Okta created/deleted these users on these dates."