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
Import the accounts with
remotePassword: { subject, login }instead oflegacyCredential(below). Each account is stored as pending: it has no password Udibo can check by itself.Register a source with an end date and switch it on (below).
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
subjectfrom the import.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.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:
{
"email": "ada@example.com",
"emailVerified": true,
"remotePassword": {
"subject": "usr_8f3a2c",
"login": "ada@example.com"
}
}subjectis 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 thissubject.loginis 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.
remotePasswordcarries no other fields.A record cannot carry both
remotePasswordandlegacyCredential. A record that breaks these rules is refused withremote_password_invalidorremote_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.
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).
Pick the end date. Checks stop at 00:00 UTC on that day (End dates).
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.
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.
Send a test call under Test call (Test the source).
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:
| 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.
Udibo's own identity tenant cannot have a source.
An endpoint source
{
"kind": "endpoint",
"url": "https://legacy.example.com/udibo/password-check",
"endsAt": "2027-03-01T00:00:00Z",
"enabled": false,
"password": "the caller's own password"
}urlmust be HTTPS to a public address. Anything else is refused underfieldErrors.url. The same check runs again before every request.The
201response carriessecret, the signing secret, startingpmsec_. 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
{
"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"
}domainis your tenant's Auth0 domain, bare or as anhttps://origin, with no path or port. It must end in.auth0.com; Auth0 custom domains are not accepted (fieldErrors.domain).realmis the name of the Auth0 database connection your users are in.clientSecretis write-only. No response returns it; reads showhasClientSecretinstead. To replace it, rotate it in Auth0 and save the new value under Where checks go → Client secret (PATCH). A newdomainorclientIdmust come with a newclientSecret(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 answers200with JSON whoseidis the one it was sent.An Auth0 source has no password-free check. The test reads
https://{domain}/.well-known/openid-configurationand passes when it answers200naminghttps://{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:
{
"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,<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:
| Outcome | Body |
|---|---|
| Right password | { "id": "<the request's id>", "result": "match", "subject": "<the subject you checked>" } |
| Wrong password | { "id": "<the request's id>", "result": "no_match" } |
idmust echo the request'sid.subjectis required onmatchand must equal thesubjectUdibo sent. It must be absent onno_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
matchorno_matchcloses 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.
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:
Create a Regular Web Application used for nothing else.
Enable the Password grant. Under the application's advanced settings, grant types, enable Password. Auth0's Free plan allows it.
Send the secret in the request body. Set the token endpoint authentication method to Client Secret (Post).
Enable your database connection for that application. Its name is the
realmyou give Udibo; it is required.Authorize no APIs for the application, so its secret can do no more than check passwords.
Use your
*.auth0.comdomain, such asexample.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 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:
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 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:
| 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) and your imported population,
pendingCountplusmigratedCount.
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.upgradedfor each migrated user, withverifierIdremote:endpointorremote:auth0;password_migration.breaker_openedwhen the breaker first opens, with thereasonof the last unavailable answer, such astimeout,statusor, for Auth0,subject_mismatch,issuer_mismatchoraudience_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
Watch the migration drain: Pending users falling and Migrated users rising under Status, or, in the dashboard's audit log, the
auth.password.upgradedevents. Some users may never sign in.Tell the remaining users before the end date that after it they will need "Forgot password?" or an emailed sign-in code or link.
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.
Delete the source, under Danger zone, when you no longer need its counts. Deleting it erases its secrets, and a new source starts its
migratedCountfrom zero.Retire the previous system's side. Take the endpoint down, or delete the dedicated Auth0 application.
Last verified 2026-11-14.

