Duplicate user created after first login during custom DB migration (Bulk + JIT), despite returning same user_id

Hi everyone,
I’ve been implementing a bulk + JIT migration flow from Azure AD B2C to Auth0, and I’ve hit a strange issue where Auth0 creates a duplicate user profile on the first login, even though my custom DB login script correctly returns the existing user_id.

I’ve spent the whole day reproducing this, and I wanted to document everything clearly for you.

Environment

  • Auth0 tenant: dev-cnhsg0roe6qny5na.us.auth0.com

  • Connection name: b2c-migrated-users

  • Connection type: Custom Database

  • Migration model: Bulk import (via /api/v2/jobs/users-imports) + JIT password validation + pure JIT account creation

    • Bulk file schema:
      {
      “email”: “billy@contoso.com”,
      “password”: “BULK_INTERNAL_ONLY__aA1!”,
      “email_verified”: true,
      “app_metadata”: {
      “migration_status”: “pending”,
      “healthcare_status”: “provider”
      }
      }

Migration flow testing steps

  1. Keep “Import users to Auth0” turned on for successful bulk migration. Run bulk import (.net console app)→ user lands in directory with
    migration_status = pending and user_id = auth0|692e5d4f9ffe27709aae8990.

  2. Turn OFF “Import users to Auth0” in connection settings.
    (“Disable signups” also ON, though preferably I believe we would want to keep this toggle off, so that we can allow new users (non b2c) to sign up in auth0 even during migration.)

  3. Login as that user through the hosted login page.
    testing url for now:
    https://dev-cnhsg0roe6qny5na.us.auth0.com/authorize?client_id=vbgZM0DB2eq73qXxGNdV2LDJEHy8j9sa&response_type=token&redirect_uri=https://jwt.io&scope=openid%20profile%20email&connection=b2c-migrated-users
    → Custom DB login script runs.
    → Script finds the same Auth0 user by email, validates credentials against legacy API, and PATCHes via Management API to mark migration_status = completed.

  4. Auth0 creates a second user instead of reusing the existing one.

Observed results

After the first successful login:

auth0|692e5d4f9ffe27709aae8990   <- bulk-imported record (status=completed)
auth0|auth0|692e5d4f9ffe27709aae8990   <- duplicate created by Auth0 core

Both have the same email and same connection (b2c-migrated-users).

Key log excerpts

LOGIN FIND_USER chosen user_id=auth0|692e5d4f9ffe27709aae8990 connection=b2c-migrated-users app_metadata={"migration_status":"pending"}
LOGIN AUTH (bulk pending) status=200 body={"success":true,"user":{"user_id":"b2c|9d38edcd-c563-4eff-b749-4cf958474e79",...}}
LOGIN MGMT PATCH response status=200 ... user_id=auth0|692e5d4f9ffe27709aae8990
LOGIN RETURNING MIGRATED USER: auth0|692e5d4f9ffe27709aae8990
LOGIN NORMALIZED returning user_id=auth0|692e5d4f9ffe27709aae8990 connection=b2c-migrated-users

Everything looks perfect – yet Auth0 still spawns a second profile.

Login script summary

  • Finds Auth0 user by email via Management API /users-by-email.

  • If migration_status=pending:

    • Calls legacy /auth API.

    • On success, PATCHes Auth0 user (password + migration_status=completed).

    • Returns the same user_id and connection:

      • const normalizedUser = {
          // Correct pattern for Auth0 DB connection users
          user_id: auth0User.user_id.replace(/^auth0\|auth0\|/, 'auth0|'),
          email: auth0User.email,
          email_verified: true,
          name: auth0User.name,
          given_name: auth0User.given_name,
          family_name: auth0User.family_name,
          app_metadata: auth0User.app_metadata || {},
          connection: dbConnection
        };
        
        
    • No create or verify logic runs.

    • I have tried testing this with this setting as well: “Disable signups” is ON.

    • “Import users to Auth0” is OFF during login.

What I expected

Auth0 should recognize the existing imported record (auth0|GUID) and reuse it after the password is patched, resulting in a single user entry.

What actually happens

Auth0 keeps the original record and creates a second one (with a double-prefixed auth0|auth0|GUID or similar variant).
This occurs even though:

  • The returned profile’s user_id matches the existing record exactly.

  • The connection is specified.

  • The password and metadata are patched successfully (for both user record profiles) before the callback.

Troubleshooting already done

  • Verified that create script never runs.

  • Tried all toggle combinations:

    • “Import users to Auth0” ON/OFF.

    • “Disable signups” ON/OFF.

  • Confirmed Management API PATCH executes and returns 200.

  • Confirmed the returned profile matches the existing Auth0 ID.

Questions

  1. Why would Auth0 still auto-create a new profile when the custom DB login returns an existing user_id?

  2. Is there a hidden setting or expected flag to tell Auth0 not to create a new record* after a successful login in a custom DB connection with bulk migration?

  3. Is this a known issue with bulk + JIT hybrid flows?

Any official insight from the Auth0 engineering or community team would be hugely appreciated, we’re building a full migration demo and want to make sure we’re following best practice.

Thanks in advance,
– Ray Garg

Hi @ray4

Welcome to the Auth0 Community!

Just come clarification regarding your bulk migration and scripts:

  • In the Bulk Import JSON, do you use the explicit ID of the users in your external database? When these users are being migrated to Auth0, the scripts should automatically append the “auth0|” prefix to the newly migrated user.
  • Do you explicitly append the “auth0|” prefix to the users during the script? If so, please try to remove that part since Auth0 automatically takes care of that during migration. This appears to be the case to me when I am reviewing the normalizedUser object you have provided above if I am not mistaken.
  1. Why would Auth0 still auto-create a new profile when the custom DB login returns an existing user_id?

Auth0 might be creating an user in your database once the migration is completed and if you are appending the “auth0|” prefix of the ID yourself, Auth0 would consider both the migrated user’s id and the appended one two different users. For the system, auth0| and auth0|auth0| are two different IDs.

  1. Is there a hidden setting or expected flag to tell Auth0 not to create a new record* after a successful login in a custom DB connection with bulk migration?

No, there is not. Basically, the Login script of your custom database takes care of everything. Only errors in the Bulk Import JSON scheme or the script itself would cause such issues.

  1. Is this a known issue with bulk + JIT hybrid flows?

I would not consider this a known issue by itself but this can happen if the migrated user’s ID if the prefix is appended or returned. Providing the explicit external user ID should do the trick.

Otherwise, you should keep Import Mode ON and disable signups if you are strictly migrating users.

If you have any other questions or the issue persists, let me know and DM me the tenant name if necessary so I can take a look.

Kind Regards,
Nik

Hey @nik.baleca ,

Quick follow-up now that I’ve wired the full migration flow end-to-end and validated both Bulk-and-JIT paths working together correctly.

Context again: I’m migrating from Azure AD B2C to Auth0 using:

  • A Custom DB login script on an Auth0 DB connection (b2c-migrated-users)
  • A .NET 8 API hosted on Azure App Service that exposes:
    • LocalAccountUserExists(existence check + profile)
    • LocalAccountSignIn(ROPC-style auth + profile)

My goals were:

  1. Bulk migrate users into Auth0 with app_metadata.migration_status = "pending".
  2. On the first successful login, validate the password against legacy (B2C), update the Auth0 password, flip migration_status = "completed". The next step will be to work on the change password database action script for forgot password scenarios, and handle those for bulk migrated, and pure JIT user use cases as well.
  3. Support pure JIT for any users that never made it into the bulk import, and mark them as completed on first login as well.

After working through it, two issues turned out to be the root of everything:


1. Correct handling of user_id vs auth0| prefix

When Auth0 creates a DB user, it exposes two IDs:

  • Layer Example Meaning Column 3 Column 4
    Top-level user_id auth0 692fd27b9ffe27709ab86c39` 692fd27b9ffe27709ab86c39
    identities.user_id 692fd27b9ffe27709ab86c39 The DB-connection “local” ID

Custom DB Login scripts must return the local ID, not the auth0|… ID.
If you return auth0|…, Auth0 will prepend it again internally → producing auth0|auth0|... and duplicate profiles.

So I now normalize it like this:

const fullAuth0Id = auth0User.user_id;
const dbIdentity = Array.isArray(auth0User.identities)
  ? auth0User.identities.find(id => id && id.connection === dbConnection)
  : null;

const localUserId =
  dbIdentity?.user_id ||
  (fullAuth0Id.startsWith('auth0|')
     ? fullAuth0Id.substring('auth0|'.length)
     : fullAuth0Id);

Then I return localUserId in the profile:

const normalizedUser = {
  user_id: localUserId,
  email: auth0User.email,
  email_verified: true,
  name: auth0User.name,
  given_name: auth0User.given_name,
  family_name: auth0User.family_name,
  app_metadata: auth0User.app_metadata || {},
  connection: dbConnection
};

This completely resolved the duplicated-ID behavior and made subsequent Management API calls predictable.

  1. Clarifying the 3 branches in the login script

My script is now structured around the 3 execution paths Auth0 expects:

(a) Internal bulk calls (no legacy calls)

Bulk import uses a special password prefix to avoid triggering legacy calls:

const BULK_PREFIX = 'BULK_INTERNAL_ONLY__';

If detected, I simply return a stub profile.
This avoids calling the legacy system during bulk seed operations.

(b) Bulk-migrated users already in Auth0 (pending → completed)

I check for an existing Auth0 user via Management API:
let auth0User = await findAuth0UserByEmail(identifier);

If found:

  • Normalize the local user_id

  • Check migration_status

  • If "pending" → validate password against legacy

  • On success:

    • Patch Auth0 password

    • Update migration_status = "completed"

    • (Second phase of work will now be getting forgot password flows set up.)

  • Return a normalized profile (with the local ID)

If "completed" → simply return normalized profile.

This path is now working flawlessly.

(c) Pure JIT (user not found in Auth0 yet)

If findAuth0UserByEmail returns null, I call:

  • LocalAccountUserExists

  • LocalAccountSignIn

One final bug was causing repeated failures:
My .NET API returns:

{ “success”: true, “user”: {…} }

…but my login script was checking:

if (existsResp.data.exists !== true)

Changing it to:

if (existsResp.data.success !== true)

fixed Pure JIT instantly.

Now Pure JIT:

  • Validates the legacy password

  • Creates the Auth0 profile

  • Sets migration_status = "completed"

  • Supports extra attributes

  • Returns normalized user with a stable local ID

    Result

    Everything is working together cleanly:

    • Bulk migration → pending → password validation → completed

    • Password update + attribute hydration via Management API

    • Pure JIT for missed users

    • No duplicated users

    • No more auth0|auth0|... IDs

    • Azure App Service logs and Auth0 logs align exactly

    I’m moving on to MFA, account recovery, and the rest of the CIAM flow now that this foundation is stable.

    Thanks again for taking the time to jump into this thread, even just having another pair of eyes helped me slow down and re-check the assumptions. Everything is now working cleanly across Bulk + JIT.

    I’ll DM you the tenant name as well in case it’s helpful for your internal visibility.
    Really appreciate the support.

    –Ray

Hi again @ray4

Glad I could help and provide insight on the matter and thank you very much for providing such an in-depth explanation of the process you have used for this type of user migration for everybody to see.

Hope to see you again around the community with any other questions or insight to offer!

Kind Regards,
Nik