Skip to content
Udibo
On this page
View Markdown

Password migration at sign-in

Bring password users across when your previous system cannot give you a password hash Udibo can read. This is sometimes called lazy migration: you import those accounts without a hash. The first time each person signs in with their old password, Udibo checks it once against your previous system. When that system confirms it, Udibo stores the password in its own format, and that person never touches the old system again.

Udibo checks passwords against one of two migration sources:

  • An endpoint you host in front of your previous system. It works with any system, including one with no API.

  • Your Auth0 tenant, through an Auth0 application you create for this. You host nothing.

Each tenant has at most one source. Every source has an on/off switch and a required end date.

When to use it instead of a hash import

Use a hash import whenever you can. It needs no network call at sign-in, and nothing stops it from working later.

Use password migration at sign-in when your previous system cannot export a hash Udibo reads:

  • it exports no hashes at all, or only through a process you cannot complete;

  • its hashes use a format Udibo does not accept, such as argon2 or bcrypt above cost 12.

The alternative is importing those accounts without a password, so each person signs in by email or sets a new password on their first visit.

What it costs you:

  • Udibo sends each pending user's plaintext password to your source, once for each sign-in attempt that reaches a check. Your source must treat it as a password: never log it, store it, or send it anywhere else.

  • Your previous system must stay up until the end date. If it does not answer, pending users cannot sign in with their password.

  • Failed sign-ins on your tenant get slower while a source is on (see What your users experience).

A user who does not sign in before the end date takes the reset path, the same as an account imported without a password.

How it works

  1. Import the accounts with remotePassword: { subject, login } instead of legacyCredential (below). Each account is stored as pending: it has no password Udibo can check by itself.

  2. Register a source with an end date and switch it on (below).

  3. A pending user signs in on your hosted sign-in page with their old password. Udibo sends one check to your source, naming the account by the subject from the import.

  4. On a match for that same subject, Udibo stores the password in its own format, clears the pending state and signs the person in. Their later sign-ins never reach your source.

  5. Anything else is refused with the normal sign-in error. The account stays pending; only its failed-attempt count can change (Lockout).

Only accounts imported with remotePassword are ever checked. An email or username that has no account is not sent to your source.

Import the accounts

Udibo runs imports for you; see Udibo runs the import for you. In each password record you send, put remotePassword where a hash would go:

JSON
{
  "email": "ada@example.com",
  "emailVerified": true,
  "remotePassword": {
    "subject": "usr_8f3a2c",
    "login": "ada@example.com"
  }
}
  • subject is your previous system's stable id for that user. It decides which old account may answer for this Udibo account: a match counts only when your source answers for this subject.

  • login is the identifier your previous system signs that user in with, such as their email or username there. Udibo sends it as a hint only.

  • Both are required, non-empty strings of at most 255 characters, with no NUL characters and valid Unicode. remotePassword carries no other fields.

  • A record cannot carry both remotePassword and legacyCredential. A record that breaks these rules is refused with remote_password_invalid or remote_password_with_credential, and the rest of the file still imports.

Everything else about the record follows the import file format.

Rehearse the mapping with one test user first. A wrong subject or login looks like a wrong password, every time, so nobody can tell from the sign-in page. Sign in as the test user with their old password: a working mapping records auth.password.upgraded with verifierId remote:endpoint or remote:auth0 in your audit log. A broken one records auth.sign_in.failed although the password was right: reason: migration_unavailable when your source answered for a different subject, or reason: wrong_password when it found no such account and answered no_match.

A mapping cannot be corrected after import. Only the import writes subject and login, and a second import skips accounts that already exist. Users whose mapping is wrong reset their password with "Forgot password?". There is no tool to correct a mapping; tell Udibo if you need one.

For Auth0, subject must equal the sub claim Auth0 puts in that user's ID token, because that is what Udibo compares. For a database-connection user this is normally the user_id from your Auth0 user export, such as auth0|65f1a2b3c4d5e6f7a8b9c0d2. login is the username Auth0's database connection accepts, usually the email address. An Auth0 user export does not carry remotePassword, so add it to each password user's record yourself.

Set up the source

Set the source up in your tenant's dashboard, under Sign-in → Migration. The page needs the Manage password migration permission, which only the Owner access level holds by default.

  1. Connect a source. With no source yet, the page offers Check against your own endpoint and Check against Auth0. Fill in one: the endpoint URL, or the Auth0 domain, client ID, client secret and realm (Auth0 setup).

  2. Pick the end date. Checks stop at 00:00 UTC on that day (End dates).

  3. Leave "Start checking passwords now" off for now, confirm with your own Udibo password, or an authenticator or recovery code when your account has no password, and connect.

  4. For an endpoint, save the signing secret. The page shows it once, right after you connect. Store it where your endpoint can read it. If you lose it, rotate it under Signing secret; that needs no old secret.

  5. Send a test call under Test call (Test the source).

  6. Turn it on under Status → Turn on, confirming again, then rehearse with one test user (Import the accounts).

After that, Status shows the source's state and counts (Monitor the migration), Where checks go changes the destination or the end date, Signing secret rotates an endpoint's secret, and Danger zone deletes the source. A change that turns the source on, points checks somewhere new, replaces a secret or moves the end date later asks you to confirm it is you, and so does connecting a source. An account with neither a password nor an authenticator app cannot connect a source or make those changes until it adds one on its security page.

The management API

The same operations exist on the management API, which customer tenants cannot get a credential for today; see the management API. They are the reference for what a source holds and what each change requires, and the field rules under them apply to the dashboard's forms too.

All of them live under https://www.udibo.com/api/identity/{tenantId}/password-migration:

OperationRequestNeeds step-up
Read the source and its countsGET /No
Create itPOST /Yes
Change itPATCH /When it turns the source on, changes its url, domain, clientId, realm or clientSecret, or moves the end date later
Delete itDELETE /No
Rotate an endpoint's signing secretPOST /secret/rotateYes
Send a test callPOST /testNo

Every write needs a person, never a machine token (refused with reason: machine_token). That person needs the Manage password migration permission. A bearer token also needs the identity:password-migration:manage scope, which is requested by name and is never part of a broader scope set. Step-up means the request body also carries the person's own password, or a two-step sign-in or recovery code when their account has no password; a refused step-up answers 403 with reason: step_up_failed, and too many tries answer 429. Each write records an admin.password_migration.* event in your audit log.

Udibo's own identity tenant cannot have a source.

An endpoint source

JSON
{
  "kind": "endpoint",
  "url": "https://legacy.example.com/udibo/password-check",
  "endsAt": "2027-03-01T00:00:00Z",
  "enabled": false,
  "password": "the caller's own password"
}
  • url must be HTTPS to a public address. Anything else is refused under fieldErrors.url. The same check runs again before every request.

  • The 201 response carries secret, the signing secret, starting pmsec_. It is shown this once. Store it where your endpoint can read it.

  • A new source starts off unless you send "enabled": true.

An Auth0 source

JSON
{
  "kind": "auth0",
  "domain": "example.us.auth0.com",
  "clientId": "your dedicated application's client ID",
  "clientSecret": "that application's client secret",
  "realm": "Username-Password-Authentication",
  "endsAt": "2027-03-01T00:00:00Z",
  "password": "the caller's own password"
}
  • domain is your tenant's Auth0 domain, bare or as an https:// origin, with no path or port. It must end in .auth0.com; Auth0 custom domains are not accepted (fieldErrors.domain).

  • realm is the name of the Auth0 database connection your users are in.

  • clientSecret is write-only. No response returns it; reads show hasClientSecret instead. To replace it, rotate it in Auth0 and save the new value under Where checks go → Client secret (PATCH). A new domain or clientId must come with a new clientSecret (fieldErrors.clientSecret).

  • A source's kind never changes. To switch kinds, delete the source and create another.

See Auth0 setup for the application this needs.

End dates

endsAt is required, and may be at most 365 days after the request that sets it (fieldErrors.endsAt). Each later save is measured from its own date, so you can extend the end date, a year at a time at most, with step-up.

Once the end date passes, Udibo sends no more checks, whatever enabled says. Turning on a source whose end date has passed is refused under fieldErrors.endsAt. For an Auth0 source, Udibo also erases the stored client secret within a few minutes of the end date, so turning it on again later needs a new clientSecret along with a future end date.

Turning it off

Choose Status → Turn off in the dashboard (PATCH with { "enabled": false }), or bring the end date closer. Neither needs step-up. While the source is off, Udibo sends nothing and pending users get the normal sign-in error for a password sign-in.

Checks need enforced protection

Udibo sends checks only while your tenant's attack protection mode is Enforce. In Monitor mode lockout and the rate limit do not stop anyone, so a source could be used to guess passwords against your previous system without limit. While monitor mode is on, or forced on for every tenant, no check is sent and pending users cannot sign in with their password.

Test the source

Test call → Send test call in the dashboard (POST /test) makes one call that carries no password, whether or not the source is on. The dashboard reports the result on the page; the API answers { "ok": true | false, "status": number | null, "reason": string | null }.

  • An endpoint receives a signed { "type": "ping", "id": "…" } and passes when it answers 200 with JSON whose id is the one it was sent.

  • An Auth0 source has no password-free check. The test reads https://{domain}/.well-known/openid-configuration and passes when it answers 200 naming https://{domain}/ as issuer. That proves the domain is right; it says nothing about the client ID, secret or realm. Rehearse with a real test user for those.

When the test fails, the dashboard says why in a sentence and the API in reason:

The dashboard saysreasonWhat happened
"it answered with a status other than 200"statusAn answer other than 200, shown as (HTTP …) and given in status. The reference endpoint below answers 401 when the signature or timestamp does not verify: check the secret it holds and its clock
"its answer was not the JSON the contract asks for"invalid_bodyA 200 whose body was not JSON or had no id (an endpoint), or did not name the domain as issuer (Auth0)
"its answer echoed a different id from the one sent"id_mismatchA 200 echoing a different id
"it answered with a redirect, which Udibo never follows"redirectA 3xx; redirects are not followed
"it did not answer in time"timeoutNo complete answer within 2 seconds
"it was refused; check that the address uses https and resolves to a public address, and that the answer is sent without compression such as gzip"refusedUdibo's outbound checks stopped the call: the address is not https, did not resolve, or resolved to an address Udibo does not send to, such as a private one; or the endpoint answered with a compressed body (any Content-Encoding other than identity). For that last one, turn off compression such as gzip or Brotli for the route, at the server and at any proxy in front of it
"it could not be reached: TLS or the connection failed"transportThe connection failed, or the answer was over 256 KiB

The endpoint contract

The request

Each check is one POST to your URL with a JSON body:

JSON
{
  "type": "password.verify",
  "id": "0b5f3c1e-7d2a-4f62-9a1e-3c4b5d6e7f80",
  "password": "the password the person typed",
  "subject": "usr_8f3a2c",
  "login": "ada@example.com"
}

subject and login are the values from the import. The body carries nothing else: no IP address and no user agent. A test call sends { "type": "ping", "id": "…" } instead.

Three headers sign it:

HeaderValue
udibo-migration-idThe request's id
udibo-migration-timestampUnix seconds when Udibo signed it
udibo-migration-signatureSpace-separated v1,<base64url> entries

The scheme is the one webhooks use: HMAC-SHA256 over {id}.{timestamp}.{raw body}, keyed by the 32 bytes the secret encodes after its pmsec_ prefix, base64url-decoded. Verify every request before reading the password from it, reject a timestamp more than 5 minutes from your clock, and accept a request when any entry matches.

Rotating the signing secret (Signing secret → Rotate secret, or POST /secret/rotate, with step-up) returns the new secret once. For the next 24 hours each request is signed with both the new and the replaced secret, so you can deploy the new one any time inside that window. Rotating again inside the window keeps only the secret that rotation replaced.

Check the right account

Check the password of the account named by subject. Use login only to help find it, never in place of it: a login can move between accounts in your previous system. If ada@example.com now belongs to a different old account, answering for that account's password would let it sign in as the account you imported.

The answer

Answer 200 with JSON:

OutcomeBody
Right password{ "id": "<the request's id>", "result": "match", "subject": "<the subject you checked>" }
Wrong password{ "id": "<the request's id>", "result": "no_match" }
  • id must echo the request's id.

  • subject is required on match and must equal the subject Udibo sent. It must be absent on no_match.

  • The body may carry profile, a JSON object, which Udibo ignores for imported accounts. Any other field makes the answer invalid.

Anything else counts as unavailable: another status, a redirect, no answer within the deadline, a body over 256 KiB, a compressed body (any Content-Encoding other than identity, so turn off gzip for this route), a body that is not JSON or breaks these rules, or a wrong id or subject. Answer no_match for an unknown subject too.

Timing, retries and the breaker

  • One deadline of 2 seconds covers Udibo's own preparation, DNS, connecting, sending and reading your answer. Answer well inside it.

  • No retries. Udibo sends each check exactly once. A new sign-in attempt is a new check with a new id.

  • The breaker. After 5 unavailable answers in a row, Udibo sends no checks for 60 seconds, then lets one trial check through. A match or no_match closes the breaker; anything else keeps it open for another 60 seconds. So while your source is down, checks stay refused, one trial a minute, until it answers again. While it is open, pending users get the normal sign-in error. The breaker is shared by every pending user on the tenant.

  • Redirects are not followed.

A reference endpoint

This handler verifies the signature, answers the test call, and checks the password of the account named by subject. checkPassword is yours: it must look the account up by subject and compare the password with your previous system's own check. It uses the Fetch API's Request and Response with node:buffer and node:crypto, so it runs on Node.js and on runtimes that provide those modules.

TypeScript
import { Buffer } from "node:buffer";
import { timingSafeEqual } from "node:crypto";

type MigrationRequest =
  | { type: "ping"; id: string }
  | {
      type: "password.verify";
      id: string;
      password: string;
      subject: string;
      login: string;
    };

type CheckPassword = (subject: string, password: string) => Promise<boolean>;

function base64urlToBytes(value: string): Uint8Array<ArrayBuffer> {
  return Uint8Array.from(
    atob(value.replace(/-/g, "+").replace(/_/g, "/")),
    (c) => c.charCodeAt(0),
  );
}

function bytesToBase64url(bytes: Uint8Array): string {
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
}

async function verifyMigrationRequest(
  body: string,
  headers: Headers,
  secret: string,
): Promise<MigrationRequest | null> {
  const id = headers.get("udibo-migration-id");
  const timestamp = Number(headers.get("udibo-migration-timestamp"));
  const header = headers.get("udibo-migration-signature");
  if (!id || !header || !Number.isInteger(timestamp)) return null;
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return null;

  if (!secret.startsWith("pmsec_")) return null;
  const keyBytes = base64urlToBytes(secret.slice("pmsec_".length));
  if (keyBytes.byteLength !== 32) return null;
  const key = await crypto.subtle.importKey(
    "raw",
    keyBytes,
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const mac = new Uint8Array(
    await crypto.subtle.sign(
      "HMAC",
      key,
      new TextEncoder().encode(`${id}.${timestamp}.${body}`),
    ),
  );
  const expected = Buffer.from(`v1,${bytesToBase64url(mac)}`);
  const signed = header.split(" ").some((entry) => {
    const candidate = Buffer.from(entry);
    return candidate.byteLength === expected.byteLength &&
      timingSafeEqual(candidate, expected);
  });
  if (!signed) return null;

  const message = JSON.parse(body) as MigrationRequest;
  return message.id === id ? message : null;
}

export async function handleMigrationRequest(
  request: Request,
  secret: string,
  checkPassword: CheckPassword,
): Promise<Response> {
  const body = await request.text();
  const message = await verifyMigrationRequest(body, request.headers, secret);
  if (message === null) return new Response(null, { status: 401 });
  if (message.type === "ping") return Response.json({ id: message.id });

  const matched = await checkPassword(message.subject, message.password);
  return Response.json(
    matched
      ? { id: message.id, result: "match", subject: message.subject }
      : { id: message.id, result: "no_match" },
  );
}

Serve it over HTTPS only, and keep request bodies out of your access logs and error reports, since every one holds a password.

Limit attempts in checkPassword. Udibo's lockout and budget apply only to checks Udibo sends. Anyone who obtains the signing secret can send signed requests straight to your endpoint, so make checkPassword go through your previous system's own failed-attempt limit or lockout for each subject, or enforce one there. If the secret may have leaked, rotate it and deploy the new one at once: the replaced secret keeps signing for 24 hours, so stop accepting it as soon as the new one is in place.

Auth0 setup

Udibo checks each password with Auth0's password grant, sent to https://{domain}/oauth/token with the password-realm grant type, your application's client ID and secret in the form body, username set to the imported login, realm set to your connection, and scope=openid. It asks for no API audience and no refresh token. A 200 counts as a match only when its ID token was issued by https://{domain}/, is addressed to your client ID and has a sub equal to the imported subject. Udibo reads those three claims and discards the tokens.

In your Auth0 tenant:

  1. Create a Regular Web Application used for nothing else.

  2. Enable the Password grant. Under the application's advanced settings, grant types, enable Password. Auth0's Free plan allows it.

  3. Send the secret in the request body. Set the token endpoint authentication method to Client Secret (Post).

  4. Enable your database connection for that application. Its name is the realm you give Udibo; it is required.

  5. Authorize no APIs for the application, so its secret can do no more than check passwords.

  6. Use your *.auth0.com domain, such as example.us.auth0.com, not a custom domain.

How Udibo reads Auth0's answers:

Auth0 answersUdibo treats it as
200 with an ID token as aboveMatch
invalid_grant (400 or 403)Wrong password
429 too_many_attempts (user blocked)Unavailable
mfa_requiredUnavailable
Any other status or error, or a timeoutUnavailable

In our test, Auth0 answered 403 invalid_grant for an unknown username as well as for a wrong password, so a login that does not exist in the connection reads as a wrong password.

If Auth0 requires two-step sign-in for some users, import them without remotePassword. For them Auth0 can answer mfa_required, which Udibo treats as unavailable. Each such sign-in then counts as a failed attempt, even with the right password, and five in a row open the breaker for every pending user. Whether Auth0 sends mfa_required only after checking the password is unverified.

Every check comes from Udibo's address, not the user's, so Auth0's protection against repeated failures sees all of them from one place. In a test on a Free tenant, Auth0 blocked a user after 10 consecutive wrong passwords with 429 too_many_attempts, and refused the correct password straight afterwards too. Unknown usernames from the same address were not blocked at that volume. See Lockout for what that means here.

What your users experience

  • They sign in as before, on your hosted sign-in page, with the email or username their imported account has and their old password. The first successful sign-in takes as long as your source takes to answer.

  • A wrong password gets the normal error, "We couldn't sign you in with that email or username and password. Check both and try again." So does every refusal described on this page: an unavailable source, a spent budget, an open breaker, a source that is off or past its end date, or monitor mode. The person cannot tell these apart, and neither can anyone guessing.

  • Failed sign-ins take longer. While a source is on and before its end date, every failed password sign-in on your tenant takes at least 2.4 seconds instead of 400 ms, for every account, so that a pending account is not told apart by its timing while your source answers well inside its deadline. Successful sign-ins are not delayed.

  • "Forgot password?" works for a pending account, if it has an email address. Completing the reset sets a new password and ends the pending state; the old password is never checked.

  • Two-step sign-in enrollments do not come across. As with any import, people enroll again on Udibo.

When someone can't sign in

The person always sees the same error, but your audit log tells the cases apart. Filter it to auth.sign_in.failed events targeting that user; the latest one's reason says which case applies:

reasonFor a pending account it meansWhat to do
no_passwordNo check was possible: no source, the source is off or past its end date, or the tenant is in monitor modeTurn the source on or move its end date later under Sign-in → Migration, switch attack protection back to Enforce, or have the person reset their password
not_checkedNothing was sent: the budget is spent, the breaker is open, the destination was refused before sending, the deadline passed first, or Udibo could not open the source's stored secretWait for the window or the breaker; see the password_migration.* events and the Status section. If neither the budget nor the breaker explains it, rotate the endpoint's signing secret under Signing secret, or save the Auth0 client secret again under Where checks go. This attempt did not count toward lockout
wrong_passwordThe source answered no_match (Auth0's invalid_grant, or your endpoint's no_match), or Udibo hit an internal error while checkingIf the person is sure of the password, check the login or subject mapping
migration_unavailableThe check was sent but no usable answer came back: a timeout, a dropped connection, an error status, a malformed answer, Auth0 429 or mfa_required, or an answer for a different subject. It counts toward lockoutCheck your source; look for password_migration.breaker_opened. Many users at once points at an outage; one user every time points at that user's mapping or, on Auth0, a blocked user
lockedThe account is locked after too many failed attempts; nothing was sentThe person uses the unlock email, waits out the lockout, or resets their password

The user page shows Password: No password for a pending account, because Udibo cannot check that password by itself. That does not mean the import lost anything.

Ways back in that never touch your source: "Forgot password?" on the sign-in page, for an account with an email address; an emailed sign-in code or link, when your tenant offers them; or Reset password on the user's page, which sets a temporary password.

Lockout

A check counts toward your lockout like any password attempt:

  • A no_match, and an unavailable answer to a check that was sent, each count as one failed attempt, because your previous system may already have counted it.

  • A refusal made before anything was sent does not count: a spent budget, an open breaker, monitor mode, or a destination refused before the request.

  • A locked or rate-limited account is never checked.

With Auth0, Udibo's lockout does not lock before Auth0 blocks the user at the defaults. Udibo locks after 10 failures, and lets those 10 checks through, which is when Auth0 blocked the test user. After that, every check for that user gets 429 too_many_attempts until the block lifts or you unblock the user in Auth0. Those answers are unavailable, so they count toward lockout and are recorded as migration_unavailable. When Auth0 describes the block as one user's ("Your account has been blocked after multiple consecutive login attempts."), they stay out of the shared breaker. With any other description, five of them in a row, with no other answered check in between, open the breaker for every pending user (see Timing, retries and the breaker). Lowering Failed attempts before lockout below 10 makes Udibo lock first within one lockout period. Whether Auth0's count of failures falls over time is unverified.

Monitor the migration

The dashboard's Status section shows migrated users, pending users, checks sent today, the state, the end date, the circuit breaker and both budget windows. GET /api/identity/{tenantId}/password-migration returns the same values:

FieldWhat it tells you
pendingCountLive accounts still pending: imported with remotePassword and not yet migrated
migratedCountAccounts this source migrated since it was created, including ones deleted since
budgetperMinute and perDay, each with limit, used and resetAt
breakeropen, consecutiveFailures and openedAt
enabled, endsAtWhether checks can be sent, and until when

The budget limits checks, not users. Each check about to be sent spends one call in both windows, whatever the answer:

  • 60 checks a minute.

  • Per day, twice the larger of your plan's included retained users (see pricing) and your imported population, pendingCount plus migratedCount.

When either window is spent, no check is sent until that window ends, and pending users get the normal sign-in error. The first refusal in each window records password_migration.budget_exhausted in your audit log. The budget is an abuse limit, not a charge, and you cannot change it.

Your audit log also shows:

  • auth.password.upgraded for each migrated user, with verifierId remote:endpoint or remote:auth0;

  • password_migration.breaker_opened when the breaker first opens, with the reason of the last unavailable answer, such as timeout, status or, for Auth0, subject_mismatch, issuer_mismatch or audience_mismatch. It is not recorded again when a failed trial keeps it open;

  • admin.password_migration.* for every change to the source and each test call.

pendingCount counts accounts that still carry the pending state. An emailed password reset clears it. A password an administrator sets from the dashboard does not, so that account keeps counting as pending, although it signs in with the new password and is never checked against your source.

Finish the migration

  1. Watch the migration drain: Pending users falling and Migrated users rising under Status, or, in the dashboard's audit log, the auth.password.upgraded events. Some users may never sign in.

  2. Tell the remaining users before the end date that after it they will need "Forgot password?" or an emailed sign-in code or link.

  3. Turn the source off, or let the end date pass. Failed sign-ins go back to 400 ms. Remaining pending accounts have no password Udibo can check, so their owners take the reset path.

  4. Delete the source, under Danger zone, when you no longer need its counts. Deleting it erases its secrets, and a new source starts its migratedCount from zero.

  5. Retire the previous system's side. Take the endpoint down, or delete the dedicated Auth0 application.

Last verified 2026-11-14.