Skip to content

User import

Purpose: move an existing user base onto the hosted identity service without making anyone reset their password. You hand over an export containing each user's existing password hash; we store it as-is, and the first time that person signs in correctly we verify against the old hash and quietly rehash into our own format. Nobody is emailed, nobody is interrupted.

This page covers both halves: what the importer accepts, and what happens on that first sign-in.

Import is operator-run today, not self-serve

There is no import endpoint and no dashboard screen. The importer is a command-line tool run against the database directly, so bringing a user base over means sending us the file and asking us to run it. There is no POST /api/identity/{tenantId}/users/import, no upload form, and no scheduled or incremental sync.

Plan around that: it is a one-shot cutover you coordinate with us, not something your own deployment pipeline can call. Everything below describes the file you prepare and what will happen to it — the mechanics are worth knowing even though you are not the one invoking them, because every rule below decides whether one of your users lands or is skipped.

Moving the other way is not symmetrical — taking your data back out is a set of ordinary scoped API calls rather than something we run for you.

The file

An array of JSON objects, or one JSON object per line (NDJSON). The format is detected from the first non-whitespace character, so either works with no flag. CSV is not accepted.

Only email is required.

FieldRules
emailRequired. Lower-cased and trimmed before anything else looks at it
usernameUp to 50 characters. Omitted, it falls back to the email's local part
firstNameUp to 100 characters, falling back to the email's local part
lastNameUp to 100 characters, falling back to the email's local part
displayNameUp to 100 characters
emailVerifiedCarry it over — an address you had already verified stays verified
legacyCredentialThe existing password hash, up to 1024 characters. See the formats below
identitiesSocial logins to relink: { provider, subject, email? }. provider and subject are capped at 255 characters; the optional email is not capped
metadataYour admin-owned bucket: a JSON object at the root, at most 16 KB serialized, 8 levels deep, no NUL characters
userMetadataThe user-editable bucket, same caps

Omit legacyCredential for an account that never had a password — an account that only ever signed in through a social provider, or a passwordless one. List its social logins in identities and it imports whole. Leave both out and the account still imports; that person signs in through whatever your tenant offers and sets a credential then.

type: "bot" is refused per record. Every imported account is a person.

Shaping your own export into that file, with the accounts that never had a password carried by their social links instead:

TypeScript
interface ImportRecord {
  email: string;
  username?: string;
  firstName?: string;
  lastName?: string;
  emailVerified?: boolean;
  legacyCredential?: string;
  identities?: { provider: string; subject: string; email?: string }[];
  metadata?: Record<string, unknown>;
}

interface SourceUser {
  email: string;
  handle: string | null;
  passwordHash: string | null;
  verifiedAt: Date | null;
  googleSubject: string | null;
  plan: string;
}

function toImportRecord(user: SourceUser): ImportRecord {
  const record: ImportRecord = {
    email: user.email,
    emailVerified: user.verifiedAt !== null,
    metadata: { plan: user.plan },
  };
  if (user.handle) record.username = user.handle;
  if (user.passwordHash) record.legacyCredential = user.passwordHash;
  if (user.googleSubject) {
    record.identities = [
      { provider: "google", subject: user.googleSubject, email: user.email },
    ];
  }
  return record;
}

function toNdjson(users: SourceUser[]): string {
  return users
    .map((user) => JSON.stringify(toImportRecord(user)))
    .join("\n");
}

Send that file over an encrypted channel and delete it once the import is confirmed: it is password material, even though every value in it is already a hash.

Which password hashes we can carry

legacyCredential must be in a format one of our verifiers recognizes, or the record is refused outright — it is not imported without a password, it is not imported at all. Check your export against this list before sending it.

FormatEncoding we read
bcrypt$2a$…, $2b$… or $2y$…
PBKDF2pbkdf2_<digest>$<iterations>$<salt>$<base64>, or $pbkdf2-<digest>$i=…$…$…; sha1, sha256 and sha512 digests
scrypt<saltHex>:<keyHex> — a 16-byte salt and 64-byte key as hex, N=16384, r=16, p=1

PBKDF2 has two bounds: an iteration count above 1,000,000 is refused, and so is a checksum shorter than 16 bytes — a short one would let anything match.

bcrypt has one too: a cost factor above 12 is refused, per record, naming the cost it found. Each step of that factor doubles the verification work, and past 12 a wrong-password refusal on that hash takes longer than the fixed deadline every refused sign-in is held to (below) — which would leave those accounts identifiable by response time, the exact signal that deadline exists to remove. A hash whose cost cannot be read is refused for the same reason. If your provider hashed above cost 12, those users cannot bring their hash across — import them with no legacyCredential and let them set a password through reset, the same fallback argon2 takes below. Upgrade-on-login cannot help there: it needs an imported hash to verify against, so an account imported without one has no password until its owner sets one.

argon2 is not accepted, and neither is any scrypt encoding other than the one above. If that is what you have, import those users with no legacyCredential and send them through your own password reset; everything else about their account still carries over.

What happens to each record

The whole batch inserts in one transaction, in chunks, so a database failure leaves nothing half-imported. A record that fails validation, though, does not abort the run — it is collected and reported, and the rest still land.

  • An email that already has a live account is skipped, silently and without error, and counted as skipped. That is what makes a re-run safe: importing the same file twice changes nothing the second time. An email freed by a deleted account is not a conflict and imports normally.

  • A username collision resolves two different ways. A username you supplied that is taken skips the whole record. One we generated from the email's local part is disambiguated instead — ada, then ada-2, ada-3 — so a batch of people sharing a local part all land.

  • Social identities dedupe on (provider, subject). A link already pointing at a live account is left alone. One pointing at a deleted account is retargeted: the old link is removed and the pair is relinked to the account being imported. So re-importing someone you previously deleted moves their social login onto the new account rather than failing — intended for exactly that case, but worth knowing before you re-import a file that overlaps with deleted users.

Idempotency is by email, not by an id of yours. There is no external-id field to match on, so if you correct a record and re-send the file, the existing account is skipped rather than updated. Fix data on the account after import rather than expecting a second import to patch it.

The run reports { imported, identitiesLinked, skipped, errors }, and each error names the record's position and what was wrong with it, so you get one list to fix rather than a stop at the first bad row. There is no dry-run mode — a validation pass and the real thing are the same run, which is a good reason to send a small sample first.

That per-record tolerance starts only after the file parses. A malformed line — one truncated NDJSON record, one stray comma — fails the whole file before any record is examined, and the failure does not say which line was bad. Validate that your export is well-formed JSON or NDJSON before sending it; that is the one error the run cannot localize for you.

The batch also writes an admin.user.imported event into your audit log, carrying imported, skipped and the number of errors. It does not record identitiesLinked — that count exists only on the run's own output, so if you need a durable record of how many social logins were relinked, capture it when the import runs.

Rate limits do not apply to the import: it writes rows directly and never touches the sign-in limiters, so a large file cannot throttle itself. The sign-in surge afterwards is limited normally, though — if you are cutting over a large user base at a fixed moment, that is the part to think about.

Upgrade-on-login

An imported account holds your old hash and no native credential. The first time that person signs in with the right password, we verify against the old hash, rehash the password into our own format, clear the old hash, and let the sign-in through. It happens inside that request, and there is nothing for you to call.

Each upgrade writes an auth.password.upgraded event naming which verifier matched, so you can watch the migration drain in your audit log rather than guessing.

Three details worth knowing, because each one is a place a reasonable assumption would be wrong:

  • A wrong password is refused the same way on an imported account as on an upgraded one. The response is identical and it counts toward lockout identically, so neither the person signing in nor your support desk can tell the two apart from what comes back. The timing matches too: every refused sign-in is held to one deadline — 400 ms, measured from the start of the attempt rather than added to it — so the extra work of checking an old bcrypt or scrypt hash is not visible from outside, and an address with no account at all takes the same time as either. That holds because the deadline is sized above the most expensive hash the importer will accept, which is why the bcrypt cost cap above exists. Three things the deadline does not cover, stated so you are not surprised: a sign-in refused by the rate limiter answers sooner, and answers that way whether or not the account exists; a submission refused by the CAPTCHA is refused before any account is looked at, so it too answers sooner, and likewise reveals nothing about the account; and a successful sign-in is never delayed, so the first upgrade-on-login for a given account can be slower than a later sign-in by that person.

  • If the rehash cannot be saved, the sign-in still succeeds. A storage failure at that moment does not deny someone a password they just proved. The old hash stays in place, no auth.password.upgraded event is written, and the next sign-in tries the upgrade again. So a missing event means "not upgraded yet", never "upgraded silently".

  • A native credential always wins. Once an account has one, the imported hash is never consulted again, so an old hash left behind cannot resurrect a password.

Only the emailed password reset clears the imported hash. Completing a reset from the link we send both sets the new credential and removes the old one. Every other way a password changes — the account's own settings page, an administrator setting a temporary one, the management API's password endpoint — sets the native credential and leaves the imported hash in the row.

That leftover is inert: once a native credential exists the imported hash is never read again, on any sign-in path. But it means "still has a legacy credential" is not a reliable measure of who is left to migrate — an account that changed its password on the settings page is fully migrated and still carries the row. Count auth.password.upgraded events, or treat the presence of a native credential as the signal, rather than the absence of the old hash.

Nothing expires an un-upgraded account. Someone who never signs in keeps their imported hash indefinitely, so the migration finishes when your users finish it, not on a deadline.

What does not come across

Import moves accounts and their sign-in methods. It does not move state that is bound to a session or a device:

  • MFA enrollments. There is no field for a TOTP secret or recovery codes, and none is imported. Users re-enroll; if your policy is required they are prompted on their first sign-in.

  • Sessions. Everyone signs in once after the move, by construction.

  • Provider-issued OAuth tokens. The link to a social account carries over — the access and refresh tokens that provider issued do not.

  • Organizations, memberships and roles. There is no field for them; create them after the accounts exist.

Limits today

  • No import endpoint, no dashboard screen, no incremental sync. One coordinated run, as above.

  • No dry-run and no report file — the counts and per-record errors are printed by the run.

  • No external-id idempotency. Matching is by email.

  • The file is read whole rather than streamed, so a very large export is bounded by the memory of the machine running it. Split it if it is huge.

  • argon2 is not among the accepted hash formats.

Last verified 2026-09-06.