Skip to content

Import users into Auth0

This guide walks through a complete import into Auth0: preparing the tenant, rehearsing with synthetic users that cover every password hash algorithm and MFA factor type you’re likely to meet, validating, importing, verifying logins, and handling the users Auth0 can’t take as-is.

The rehearsal uses iamigrate testdata generate, so you can run the whole flow against a development tenant before touching real data. Once it works end to end, swap the fixture for your own CMF export (see Step 9).

iamigrate import auth0 uses Auth0’s bulk user import job (POST /jobs/users-imports). For each CMF user it:

  1. Translates the password hash into Auth0’s password_hash or custom_password_hash format.
  2. Translates portable MFA enrollments into mfa_factors, so users don’t have to enroll again.
  3. Batches users into chunks that stay under Auth0’s 500KB per-job limit, then submits each chunk and polls it until it finishes.
  4. Collects per-user results, plus any follow-up actions users need, in import-report.json.

If organizations.cmf.jsonl or roles.cmf.jsonl sit next to the users file, a second phase creates the organizations and roles, then assigns memberships.

  • iamigrate installed (see Installation).
  • An Auth0 tenant. Use a dedicated development tenant for the rehearsal. Imported users stay in it until you delete them.
  • Dashboard access with the Admin or Editor - Users role.

Users are imported into a single database connection. In the Auth0 Dashboard, go to Authentication → Database and either create a connection or open an existing one. The connection can use the Auth0 user store, or a custom database with Import Users to Auth0 turned on.

Note the connection name, or copy its identifier (it looks like con_XXXXXXXXXXXXXXXX). You can pass the name as --connection or the identifier as --connection-id. If the tenant has only one database connection, you can leave out both and iamigrate uses that one. Looking up a connection by name, or automatically, needs the read:connections scope on your token. --connection-id works without it.

Imported totp, sms, and email enrollments only work if the matching factor is enabled on the tenant. Under Security → Multi-factor Auth, turn on the factors your users have:

CMF factorAuth0 factor to enable
totpOne-time Password
smsPhone Message (SMS and/or voice)
emailEmail

Create a Machine-to-Machine application authorized for the Auth0 Management API, and grant it these scopes:

ScopeNeeded for
create:users, read:usersBulk import jobs, job status and errors, diff auth0
read:connectionsFinding the database connection by name (--connection) or automatically (not needed with --connection-id)
update:usersAssigning global roles to users
read:roles, create:rolesRoles phase (only if you import roles.cmf.jsonl)
read:organizations, create:organizations, create:organization_members, create:organization_member_rolesOrganizations phase (only if you import organizations.cmf.jsonl)

Then export the tenant domain and the application’s credentials. iamigrate exchanges them for a Management API token through the client_credentials grant, and renews the token before it expires, so long imports don’t fail midway:

Terminal window
export AUTH0_DOMAIN=your-tenant.us.auth0.com
export AUTH0_CLIENT_ID=... # the Machine-to-Machine application's Client ID
export AUTH0_CLIENT_SECRET=... # and its Client Secret

If you’d rather not hand iamigrate the client secret, export a Management API access token instead:

Terminal window
export AUTH0_TOKEN=eyJhbGciOi... # Management API access token

Management API tokens expire (after 24 hours by default) and iamigrate can’t renew them. For large imports, get a fresh token right before you start.

Generate a fixture that mixes the password hash algorithms and MFA setups you’ll find in most real user bases:

Terminal window
iamigrate testdata generate \
--count 200 \
--hash bcrypt:cost=10 \
--hash bcrypt:cost=12 \
--hash scrypt:cost=16384,blockSize=8,parallelization=1,keylen=32 \
--hash pbkdf2:digest=sha256,iterations=100000,keylen=32 \
--hash argon2:memory=65536,time=2,parallelism=1 \
--hash sha256 \
--hash md5 \
--hash hmac \
--hash ldap \
--mfa totp:rate=0.3 \
--mfa sms:rate=0.1 \
--mfa email:rate=0.1 \
--mfa webauthn:rate=0.05 \
--mfa recovery_codes:rate=0.2 \
--seed 42 \
--out ./fixtures/

Hash algorithms are spread evenly across users. Each MFA type is drawn independently, so some users end up with several factors, such as totp plus recovery_codes. Together this covers:

CaseWhy it matters for Auth0
bcrypt cost 10The one case Auth0’s simple password_hash field accepts
bcrypt cost 12Needs custom_password_hash
scrypt, pbkdf2, argon2Modern KDFs, each with parameters that must survive translation
sha256, md5Legacy unsalted/salted digests
hmac, ldap ({SSHA})Keyed hashes and LDAP directory exports
totp, sms, emailMFA enrollments Auth0 can import
webauthn, recovery_codesMFA Auth0 can’t import. Affected users need follow-up

The command writes:

fixtures/
├── users.cmf.jsonl.gz # the users, in CMF
├── manifest.json # counts per hash algorithm and non-portable MFA type
├── answer-key.json # cleartext password + TOTP secret per user (gitignored)
└── .gitignore

answer-key.json is what lets you prove imported users can actually sign in. Keep it out of version control.

Terminal window
cat fixtures/manifest.json
{
"record_count": 200,
"hash_algorithm_counts": {
"argon2": 22,
"bcrypt": 46,
"hmac": 22,
"ldap": 22,
"md5": 22,
"pbkdf2": 22,
"scrypt": 22,
"sha256": 22
},
"non_portable_mfa_counts": {
"recovery_codes": 48,
"webauthn": 14
}
}

With a real export, this is where you find out which algorithms you’re dealing with. non_portable_mfa_counts tells you, before importing anything, how many users will need to re-enroll or regenerate recovery codes.

4. Validate against Auth0 (no network calls)

Section titled “4. Validate against Auth0 (no network calls)”
Terminal window
iamigrate validate --in ./fixtures/users.cmf.jsonl.gz --target auth0
checked 200 users against auth0: 0 problem(s)

validate checks every user against what Auth0 can import:

  • Password algorithms: all eleven custom_password_hash algorithms are supported: bcrypt, scrypt, pbkdf2, argon2, md4, md5, sha1, sha256, sha512, hmac, ldap. Any other algorithm is reported as a problem.
  • MFA: a portable factor Auth0 can’t import is reported. For example, push enrollments fail with unsupported MFA type "push".

Factors marked non-portable in CMF (webauthn, recovery_codes) are not validation errors. By definition they can’t move to any provider, so the import skips them and lists the affected users in the report (see Step 7).

5. Understand how hashes and factors are translated

Section titled “5. Understand how hashes and factors are translated”

You don’t configure anything in this step. It explains what ends up in Auth0, which helps when you read the import report.

CMF algorithmAuth0 fieldNotes
bcrypt ($2a$/$2b$, cost 10)password_hashOnly without --upsert. password_hash can be set once and never updated.
bcrypt (any other cost, or $2y$)custom_password_hash (bcrypt)
scryptcustom_password_hash (scrypt)Requires params.keylen and a power-of-two params.cost
pbkdf2custom_password_hash (pbkdf2)Re-encoded as a PHC string. Requires a salt.
argon2custom_password_hash (argon2)Requires the full PHC string ($argon2id$v=19$...)
md4, md5, sha1, sha256, sha512custom_password_hashHash (and salt) must be hex or base64 encoded. The salt position is preserved.
hmaccustom_password_hash (hmac)Requires hash.digest and the HMAC key
ldapcustom_password_hash (ldap){CRYPT} values are not supported by Auth0
Any hash marked portable: falsenoneThe user is imported without a password and listed under requires_password_reset

A user whose hash is missing a required parameter isn’t sent to Auth0. It is reported as TRANSLATION_ERROR in failed.

CMF factorAuth0 result
totpImported as {"totp": {"secret": ...}}. The user keeps their existing authenticator app.
smsImported as a verified phone factor
emailImported as a verified email factor
webauthn, pushNot imported. The user is listed under requires_reenrollment.
recovery_codesNot imported. The user is listed under requires_recovery_code_regen.

source_id becomes the Auth0 user_id, and Auth0 adds the connection prefix (so fx_ae0b6d305f0f4a20 becomes auth0|fx_ae0b6d305f0f4a20). The primary email and its verified flag, username, the first phone number and its verified flag (as phone_number and phone_verified), given_name, family_name, name, nickname, picture, blocked, app_metadata, and user_metadata are copied as-is.

Auth0’s bulk import requires an email. A user whose only identifiers are a username or a phone number isn’t sent to Auth0, and is reported under failed with TRANSLATION_ERROR.

Terminal window
iamigrate import auth0 \
--in ./fixtures/users.cmf.jsonl.gz \
--connection-id con_XXXXXXXXXXXXXXXX
imported: 173 succeeded, 27 failed -> report fixtures/import-report.json
27 failed: MFA_FACTORS_FAILED

Users that already exist in the connection aren’t counted as failures. They’re listed on a separate line, for example 12 already exist: not updated; re-run with --upsert to update them.

The test data generator currently writes SMS phone numbers without the +<country code> prefix Auth0 requires, so expect the 27 users with an sms factor to fail with MFA_FACTORS_FAILED. This is a useful dry run of the format issue described in Step 4. To get a clean run, leave out --mfa sms:rate=0.1 when you generate the fixture.

Chunks are submitted one at a time and each job is polled until it finishes, so Auth0’s limit of two concurrent import jobs per tenant isn’t a concern. Don’t start a second import against the same tenant while one is running.

Open the report:

Terminal window
cat fixtures/import-report.json
{
"succeeded": ["fx_ae0b6d305f0f4a20", "..."],
"failed": [
{ "source_id": "fx_...", "code": "MFA_FACTORS_FAILED", "message": "..." }
],
"requires_reenrollment": ["fx_a91990ed808aa2cc", "..."],
"requires_recovery_code_regen": ["fx_e9e838f2b3b9f8fe", "..."]
}
FieldWhat to do
failedEach entry has a source_id, an Auth0 error code, and a message. See Troubleshooting.
requires_password_resetThe user has no importable password. Send a password reset email, or let them use “Forgot password” at first login.
requires_reenrollmentWebAuthn/passkey or push enrollments were dropped. Ask the user to enroll again at next login.
requires_recovery_code_regenThe old recovery codes don’t work in Auth0. The user gets a new one when they next sign in with MFA.

To get a list you can feed into an email campaign or a script:

Terminal window
jq -r '.requires_reenrollment[]?' fixtures/import-report.json > reenroll.txt
Terminal window
iamigrate diff auth0 --in ./fixtures/users.cmf.jsonl.gz

This looks up every user by email, or by username or phone number for users without one, and reports any that are missing from the tenant, plus any mismatch in blocked status. Users with none of the three are listed under no identifier.

A user showing up in the tenant doesn’t prove their hash was translated correctly. Pick at least one user per algorithm from answer-key.json, then sign in as each one:

Terminal window
jq '.entries[0]' fixtures/answer-key.json
{
"source_id": "fx_ae0b6d305f0f4a20",
"email": "jonathon.marquardt@wilkinson.biz",
"password": "A@MkjAZV74rJ8XWZ"
}

Use Authentication → Database → your connection → Try Connection in the Dashboard, or any application that has the connection enabled. For users with an imported TOTP factor, the answer key also includes their totp_secret. Generate the current code with any TOTP tool, for example:

Terminal window
oathtool --totp -b <totp_secret>

A successful sign-in shows that the hash parameters, salt, and encoding were all translated correctly.

Delete the test users before you import real ones. Remove them from User Management → Users, or delete the connection and recreate it. Users deleted by other means can still exist in Auth0’s user store, which causes DUPLICATED_USER on later imports.

Once the rehearsal passes, repeat steps 3–8 with your real data:

  1. Export your source into CMF with iamigrate export, for example from a flat CSV/JSON file or from Ory Kratos (see Export users from Ory Kratos).
  2. Review mapping.yaml with iamigrate map, especially custom fields headed for app_metadata/user_metadata.
  3. Run validate and fix every reported problem at the source.
  4. Import a small canary batch first, and verify logins for it.
  5. Import the rest, then run diff auth0 and handle the follow-up lists.
Error code / symptomCauseFix
TRANSLATION_ERRORThe CMF hash is missing a parameter Auth0 needs (for example scrypt keylen, a pbkdf2 salt, or an argon2 PHC string)Fix the export for that algorithm and re-import the affected users
DUPLICATED_USERWithout --upsert: the user already exists in the connection. With --upsert: the user is left over in Auth0’s user store (possibly from an earlier, deleted import)Without --upsert: re-run with --upsert to update existing users. With --upsert: delete the user through the Connection Users endpoint, then re-import.
MFA_FACTORS_FAILEDAn MFA value doesn’t match Auth0’s format: a phone number without + and country code, or a TOTP secret that isn’t unpadded Base32Normalize the values in the export
Login fails for one algorithm onlyWrong hash parameters or encoding for that algorithmCheck the CMF params/encoding against the source system. Re-import with --upsert (only possible if the user hasn’t logged in yet).
401/403 from the Management APIExpired token, wrong client credentials, or missing scopeGet a new token (or check AUTH0_CLIENT_ID/AUTH0_CLIENT_SECRET), and check the scopes in Step 1
Import job failedAuth0 rejected the job itself (for example, a job timed out after 2 hours)Re-run. Users that already succeeded report as duplicates unless you pass --upsert.