# 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](https://www.udibo.com/docs/identity/user-import#which-password-hashes-we-can-carry) 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](https://www.udibo.com/docs/identity/password-migration#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](https://www.udibo.com/docs/identity/password-migration#import-the-accounts)). 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](https://www.udibo.com/docs/identity/password-migration#set-up-the-source)). 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](https://www.udibo.com/docs/identity/password-migration#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](https://www.udibo.com/docs/identity/user-import#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](https://www.udibo.com/docs/identity/user-import#the-file). **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](https://www.udibo.com/support) 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](https://www.udibo.com/docs/identity/password-migration#auth0-setup)). 2. **Pick the end date.** Checks stop at 00:00 UTC on that day ([End dates](https://www.udibo.com/docs/identity/password-migration#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](https://www.udibo.com/docs/identity/password-migration#test-the-source)). 6. **Turn it on** under **Status** → **Turn on**, confirming again, then rehearse with one test user ([Import the accounts](https://www.udibo.com/docs/identity/password-migration#import-the-accounts)). After that, **Status** shows the source's state and counts ([Monitor the migration](https://www.udibo.com/docs/identity/password-migration#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](https://www.udibo.com/docs/identity/apis#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`: | Operation | Request | Needs step-up | | ---------------------------- | ----------------------- | --------------------------------------------------- | | Read the source and its counts | `GET /` | No | | Create it | `POST /` | Yes | | Change it | `PATCH /` | When it turns the source on, changes its `url`, `domain`, `clientId`, `realm` or `clientSecret`, or moves the end date later | | Delete it | `DELETE /` | No | | Rotate an endpoint's signing secret | `POST /secret/rotate` | Yes | | Send a test call | `POST /test` | No | 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](https://www.udibo.com/docs/identity/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](https://www.udibo.com/docs/identity/password-migration#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](https://www.udibo.com/docs/identity/account-protection#enforce-or-monitor) 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 says | `reason` | What happened | | ------------------ | -------- | ------------- | | "it answered with a status other than 200" | `status` | An 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_body` | A `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_mismatch` | A `200` echoing a different `id` | | "it answered with a redirect, which Udibo never follows" | `redirect` | A `3xx`; redirects are not followed | | "it did not answer in time" | `timeout` | No 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" | `refused` | Udibo'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" | `transport` | The 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: | Header | Value | | --------------------------- | --------------------------------------------- | | `udibo-migration-id` | The request's `id` | | `udibo-migration-timestamp` | Unix seconds when Udibo signed it | | `udibo-migration-signature` | Space-separated `v1,` entries | The scheme is the one [webhooks](https://www.udibo.com/docs/identity/webhooks#signature-verification) 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: | Outcome | Body | | -------------- | ------------------------------------------------------------- | | Right password | `{ "id": "", "result": "match", "subject": "" }` | | Wrong password | `{ "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. ```ts 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; function base64urlToBytes(value: string): Uint8Array { 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 { 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 { 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 answers | Udibo treats it as | | ------------------------------------- | ------------------ | | `200` with an ID token as above | Match | | `invalid_grant` (`400` or `403`) | Wrong password | | `429 too_many_attempts` (user blocked) | Unavailable | | `mfa_required` | Unavailable | | Any other status or error, or a timeout | Unavailable | 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](https://www.udibo.com/docs/identity/password-migration#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](https://www.udibo.com/docs/identity/audit-log#filters) tells the cases apart. Filter it to `auth.sign_in.failed` events targeting that user; the latest one's `reason` says which case applies: | `reason` | For a pending account it means | What to do | | ---------------- | ------------------------------------------------------------------------------------- | ---------- | | `no_password` | No check was possible: no source, the source is off or past its end date, or the tenant is in monitor mode | Turn 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_checked` | Nothing 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 secret | Wait 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_password` | The source answered `no_match` (Auth0's `invalid_grant`, or your endpoint's `no_match`), or Udibo hit an internal error while checking | If the person is sure of the password, check the `login` or `subject` mapping | | `migration_unavailable` | The 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 lockout | Check 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 | | `locked` | The account is locked after too many failed attempts; nothing was sent | The 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](https://www.udibo.com/docs/identity/account-protection#configure-lockout-and-the-sign-in-rate-limit) 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](https://www.udibo.com/docs/identity/password-migration#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: | Field | What it tells you | | ---------------- | --------------------------------------------------------------------------------- | | `pendingCount` | Live accounts still pending: imported with `remotePassword` and not yet migrated | | `migratedCount` | Accounts this source migrated since it was created, including ones deleted since | | `budget` | `perMinute` and `perDay`, each with `limit`, `used` and `resetAt` | | `breaker` | `open`, `consecutiveFailures` and `openedAt` | | `enabled`, `endsAt` | Whether 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](https://www.udibo.com/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._