Bulk identity operations
Mass updates / deletes / queries
Sometimes you need to operate on many users at once: deactivate everyone from a deprecated tenant, add a trait to all users, etc.
Pattern: SQL for read, API for write
Reads (lookups, exports): direct SQL is fast.
SELECT id, traits->>'email' FROM identities WHERE state = 'inactive';Writes: Kratos admin API for safety (validation, hooks, audit).
for (const id of ids) {
await fetch(`${KRATOS_ADMIN}/admin/identities/${id}`, {
method: "PATCH",
body: JSON.stringify([{ op: "replace", path: "/state", value: "inactive" }]),
});
}DON'T directly UPDATE identities SET state='inactive' in SQL, bypasses Kratos's validation and hooks.
Operations
Bulk deactivate
Deactivate all users with no logins in 1 year:
SELECT i.id FROM identities i
LEFT JOIN security_audit a ON a.identity_id = i.id AND a.event_type = 'login'
GROUP BY i.id
HAVING MAX(a.created_at) < NOW() - INTERVAL '1 year' OR MAX(a.created_at) IS NULL;For each:
await kratos.adminPatchIdentity(id, [
{ op: "replace", path: "/state", value: "inactive" }
]);Bulk role change
Promote all "support" users to "support_l2":
const supports = await kratos.adminListIdentities({ filter: 'traits.role eq "support"' });
for (const s of supports) {
await kratos.adminPatch(s.id, [{ op: "replace", path: "/traits/role", value: "support_l2" }]);
}Bulk delete
For GDPR cleanup, delete identities inactive 5+ years:
const old = await db`
SELECT id FROM identities
WHERE state = 'inactive'
AND updated_at < NOW() - INTERVAL '5 years'
`;
for (const { id } of old) {
await kratos.adminDeleteIdentity(id);
}Sit with this one before running. Once gone, gone.
Bulk add credential
Migrating users to a new MFA factor, can't be done via API automatically (users must enroll). But you can flag them:
await kratos.adminPatch(id, [
{ op: "add", path: "/metadata_admin/require_webauthn_enrollment", value: true }
]);App reads flag, prompts user.
Bulk export
psql -d olympus -c "COPY (SELECT id, traits FROM identities) TO STDOUT WITH CSV HEADER" > all-identities.csvFor GDPR, periodic backups, analysis.
Throttling
Don't slam the admin API. Throttle:
async function processBulk<T>(items: T[], fn: (item: T) => Promise<void>, concurrency = 5) {
const queue = [...items];
const workers = Array.from({ length: concurrency }, async () => {
while (queue.length > 0) {
const item = queue.shift();
if (item) await fn(item);
}
});
await Promise.all(workers);
}
await processBulk(ids, async (id) => {
await kratos.adminPatch(id, [/* ... */]);
}, 5);5 concurrent operations. Plenty for most bulk ops, doesn't overload Kratos.
For very large (100k+):
await processBulk(ids, fn, 10);Or break into smaller batches with sleep between:
const BATCH = 1000;
for (let i = 0; i < ids.length; i += BATCH) {
const batch = ids.slice(i, i + BATCH);
await processBulk(batch, fn);
await sleep(1000); // breathe
}Idempotency
Operations should be safe to retry:
// Safe
async function deactivate(id: string) {
const identity = await kratos.adminGet(id);
if (identity.state === "inactive") return; // already done
await kratos.adminPatch(id, [{ op: "replace", path: "/state", value: "inactive" }]);
}Re-running this after partial failure: skips already-done. Good.
Audit
Bulk operations should be audited at the batch level:
INSERT INTO security_audit (event_type, actor_id, metadata)
VALUES (
'bulk_operation',
$admin_id,
$${
"operation": "deactivate_inactive",
"count": 1234,
"started_at": "2026-05-15T10:00:00Z",
"completed_at": "2026-05-15T10:05:00Z",
"errors": []
}$
);Per-identity audit too (each individual change). Both levels useful.
Confirmation
For destructive bulk ops, multi-step confirmation:
$ npm run bulk-deactivate -- --filter "last_login < 2025-05-01"
Identifying targets...
1,234 identities will be deactivated.
First 5 examples:
alice@example.com (last login: 2024-11-20)
bob@example.com (last login: 2024-10-15)
...
Type 'DEACTIVATE' to confirm:For UI: a typed confirmation in modal.
Rollback
What if the op is wrong? Build undo when possible:
// Save state before
const targets = await getTargets();
await fs.writeFile("backup-before-deactivate.json", JSON.stringify(targets));
// Operate
await processBulk(targets, deactivate);
// To undo:
const backup = JSON.parse(await fs.readFile("backup-before-deactivate.json"));
for (const t of backup) {
if (t.state === "active") {
await kratos.adminPatch(t.id, [{ op: "replace", path: "/state", value: "active" }]);
}
}For deletions: no undo. Backup first.
Per-tenant filtering
For multi-tenant Olympus:
SELECT id FROM identities WHERE traits->>'tenant_id' = 'tenant-X';Tenant-scoped bulk operations. Always include in filters to avoid affecting other tenants.