Migrating users in
Schleuse has a machine API for creating users in bulk, built for one job: moving an existing realm onto it without making everyone reset their password.
It carries over
- the password, as an existing hash — argon2, bcrypt or PBKDF2 — verified as-is and transparently upgraded to argon2id on each user's next sign-in;
- the identity, so downstream apps that store the OIDC
subas a foreign key keep resolving the same person (see Preserving thesub— this works fully when the oldsubis a UUID); - the state: verified-email flag, original creation date, suspension, and per-application OIDC roles.
It is disabled until you configure a token, and it cannot grant admin.
1. Enable the API
Set NUXT_ADMIN_API_TOKENS_JSON to a JSON array of labelled tokens:
NUXT_ADMIN_API_TOKENS_JSON='[{"id":"migration-importer","token":"'"$(openssl rand -hex 32)"'"}]'idis a label ([a-z0-9_-]), not a secret — it identifies the caller in the audit log, so you can tell later which importer created which account.tokenmust be at least 32 characters.- Unset or
[]⇒ every/api/provisioning/**route returns 404.
This token can create accounts
Treat it like a database password. Keep the endpoints off the public internet where you can, and remove the token once the migration is done. It cannot set is_admin — admin promotion stays a deliberate action in the admin panel — but it can create sign-in-capable users.
Rotation is add-then-remove: put a second entry in the array, deploy, move your importer over, then drop the first. Both are accepted in the meantime, so there is no window where neither works.
2. Understand what happens to the sub
users.id is the OIDC sub, and on PostgreSQL it is a real uuid column with seven foreign keys pointing at it. So:
Old sub you supply as legacySub | Issued sub | Downstream apps |
|---|---|---|
A UUID (f81d4fae-7dec-…) | identical — used verbatim as users.id | keep working, no change |
Anything else (12345, auth0|abc) | a new UUID | must re-map once |
Keycloak, Authentik and Zitadel all issue UUID subs, so the common migration is lossless. For a non-UUID sub the old value is still recorded (in users.legacy_sub) and returned in the import response, so you can build a legacySub → sub mapping table for the apps that need it.
Namespacing trade-off
If two source systems both number users from 1, you can namespace (keycloak:1234) to keep legacySub unique. But a namespaced value can never be issued as a sub verbatim — namespace only when the value is purely for mapping.
3. Convert your password hashes
The API takes one self-describing crypt/PHC string plus an optional algorithm label, the same shape Zitadel uses:
"hashedPassword": { "value": "$2b$12$…", "algorithm": "bcrypt" }The label is redundant on purpose: if it disagrees with the string's own prefix, the row is rejected at import — which catches a mis-mapped export column while it is still one bad line in a dry run, instead of an unexplainable failed login later.
Supported formats
| Source hash | value looks like | algorithm |
|---|---|---|
| argon2id / argon2i | $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash> | argon2id, argon2i |
bcrypt ($2a$, $2b$, $2y$) | $2b$12$<22-char salt><31-char digest> | bcrypt |
| PBKDF2-SHA1 / -SHA256 / -SHA512 | $pbkdf2-sha256$<rounds>$<salt>$<hash> | pbkdf2-sha256, … |
Notes:
- The PBKDF2 encoding follows the passlib / zitadel-passwap convention, so a hash those tools produced imports verbatim. Payloads may be standard base64 or passlib's adapted base64 (
+written as.), padded or not, androundsmay be bare (27500) or PHC-style (i=27500). - The bare
$2$bcrypt revision (1999, superseded by$2a$) is rejected: the library we verify with cannot check those hashes, so importing one would create a credential that never authenticates. Re-hash or invite those users instead. - Parameters are bounded in both directions. A digest shorter than 16 bytes, a PBKDF2 salt under 8 bytes, argon2 below
m=4096/t=1, or a bcrypt cost outside 4–15 is rejected. Every real-world export clears these comfortably — if yours doesn't, the usual cause is a truncating transform, not a genuinely weak source hash. That is exactly why the check exists: verification derives as many bytes as the stored digest holds, so a silently truncated digest would produce accounts that almost any password can open. - scrypt, sha2crypt (
$5$/$6$), md5-crypt, phpass and Drupal 7 are not supported yet. The design makes each a small additive change — open an issue if you need one.
From Keycloak
kc.sh export --users realm_file writes each credential as two JSON strings:
{
"credentialData": "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\"}",
"secretData": "{\"value\":\"CjKccTEV…QZu7zQ==\",\"salt\":\"hKBK5jFNE1ywywOrr7VLBg==\"}"
}Combine them into one string — this is the whole transform:
function keycloakToEncoded (credentialData, secretData) {
const { algorithm, hashIterations } = JSON.parse(credentialData)
const { value, salt } = JSON.parse(secretData)
const strip = (s) => s.replace(/=+$/, '')
return `$${algorithm}$${hashIterations}$${strip(salt)}$${strip(value)}`
}Then build the payload from the export:
import { readFileSync } from 'node:fs'
const realm = JSON.parse(readFileSync('realm-users-0.json', 'utf8'))
const users = realm.users.map((u) => {
const cred = (u.credentials ?? []).find((c) => c.type === 'password')
return {
email: u.email,
firstName: u.firstName || u.username,
lastName: u.lastName || undefined,
emailVerified: !!u.emailVerified,
suspended: u.enabled === false,
legacySub: u.id, // Keycloak ids are UUIDs → sub preserved
createdAt: u.createdTimestamp ? new Date(u.createdTimestamp).toISOString() : undefined,
...(cred ? { hashedPassword: {
value: keycloakToEncoded(cred.credentialData, cred.secretData),
algorithm: JSON.parse(cred.credentialData).algorithm,
} } : { invite: true }), // no password on file → send an invite
}
})
console.log(JSON.stringify({ users }, null, 2))Why the derived-key length is never hardcoded
Keycloak's pbkdf2-sha256 provider historically produced a 512-bit key despite the SHA-256 name (keycloak#16797), and was later corrected to 256 bits. Schleuse takes the key length from the stored hash itself, so both old and new exports verify with no version flags.
4. Rehearse with a dry run
dryRun runs every validation and conflict check and writes nothing:
curl -sX POST http://localhost:3000/api/provisioning/users/import \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "$(jq '. + {dryRun: true}' users.json)" | jq '{created, conflicts, invalid}'Because duplicate detection also looks at rows within the request, a dry run reports the same per-item statuses a real run would — including a file that contains the same email twice.
Fix everything the dry run flags, then drop dryRun to import for real. Batches are capped at 500 users; split larger exports and run them in sequence.
5. Read the results
Every user gets a row; a bad row never fails the batch:
{
"total": 3, "created": 2, "skipped": 0, "conflicts": 1, "invalid": 0, "failed": 0,
"dryRun": false,
"results": [
{ "index": 0, "email": "erika@example.com", "status": "created",
"id": "f81d4fae-7dec-11d0-a765-00a0c91e6bf6",
"legacySub": "f81d4fae-7dec-11d0-a765-00a0c91e6bf6", "subPreserved": true },
{ "index": 1, "email": "old@example.com", "status": "created",
"id": "3f2a…", "legacySub": "12345", "subPreserved": false },
{ "index": 2, "email": "dupe@example.com", "status": "conflict", "code": "emailExists",
"message": "a user with this email already exists" }
]
}subPreserved: false is your cue that this user needs a downstream re-map. Save the legacySub → id pairs — or query them later:
curl -s "http://localhost:3000/api/provisioning/users/lookup?legacySub=12345" \
-H "Authorization: Bearer $TOKEN"Re-running an import
Re-running is safe. A row whose email exists and whose legacySub matches what was recorded for that user comes back skipped / alreadyImported and changes nothing.
Email matching ignores case
Imported addresses are canonicalised (trimmed, lowercased) on the way in. An export holding both A@example.com and a@example.com therefore yields one user plus one conflict / duplicateInBatch — not two accounts. By the same token, re-running an export whose casing differs from the first run is still idempotent.
Always send legacySub if you might re-run
legacySub is what makes a re-run idempotent. Without it, an existing email is reported as conflict / emailExists instead of skipped — deliberately, because an email that already exists with no recorded origin is a real clash rather than a repeat of the same import.
Full status and code tables are in the API reference.
6. After the migration
- Users sign in with their existing passwords. On each first successful sign-in the stored hash is silently replaced with argon2id — no session is invalidated and no password-history entry is written, because nothing changed for the user.
- Until then those accounts carry the source system's hash, which is very likely weaker than argon2id. If your old KDF was weak, treat a forced reset as the safer option rather than importing.
- Remove the provisioning token once you are done.
What does not come across
| Why | |
|---|---|
| Passkeys / WebAuthn | Bound to the old relying-party ID; they cannot be transplanted. Users re-enrol on /account. |
| TOTP secrets | Would need re-encrypting under NUXT_TOTP_ENCRYPTION_KEY; re-enrolment is safer. |
| Sessions | Everyone signs in once against the new IdP. |
| Admin rights | is_admin is not settable through the API — promote in the admin panel. |
| Group / org structures | Out of scope. Model them as per-application roles. |
| Password-change-required flags | No such mechanism exists here; the field is rejected rather than silently ignored. |