Migrating from Auth0 to Nautilus

This guide moves an application's users from Auth0 to Nautilus, into its sign-in and users API (the Gate API, called Gate below). Done this way, your users keep their passwords: Auth0 stores them as bcrypt hashes, Gate accepts a bcrypt hash when it creates a user, and the user signs in with the password they already had. Social-login users keep their Google or GitHub account, and the data you kept in Auth0's metadata comes along.

Everything below was run against Gate: users created with $2a$ and $2b$ hashes signed in with their original passwords through the login flow, a user imported with a social account signed in through that provider and landed on the imported identity, and roles imported into an organization answered permission checks. The scripts use @verne-software/sdk 1.4.1 on Node.js 20.

What does not move, and what you have to plan around, is listed first.

What moves and what does not

From Auth0In Gate
Email addresstraits.emailMoves. Gate identifies a user by email address, so a user with none (a phone-number or username-only account) cannot be imported.
Password (database connection)bcrypt hash, imported as isMoves, if you get the hashes from Auth0 — see below. Without a hash the user is imported with no password.
Google, GitHub sign-inA linked social account (provider and subject)Moves, for providers that give every application the same account id. See Social-login users.
user_metadata, non-secret app_metadatametadata_publicMoves. The user can read metadata_public, so leave secrets behind.
user_idA new identity idGate assigns its own ids. The script keeps the Auth0 id in metadata_public.auth0_user_id and writes a map of old to new.
Organizations, roles, permissionsOrganizations, roles, permissionsMoves, through the organizations API. See Roles and organizations.
email_verifiedemail_verified on the create callMoves, as a flag you send. An address Auth0 had confirmed arrives confirmed; one it had not starts unconfirmed. See Confirmed addresses.
MFA enrolments—Not imported. Users who had a second factor enrol again (MFA / TOTP).
Sessions and refresh tokens—Not transferable. Every user signs in once after the cut-over.

1. Export from Auth0

You need two things from Auth0: the users, and their password hashes. They come by different routes.

The users come from Auth0's bulk export job — POST /api/v2/jobs/users-exports on the Management API, described in Bulk User Exports. Ask for JSON (newline-delimited, one user per line) and include at least these fields: user_id, email, email_verified, identities, user_metadata. Add app_metadata if there is something in it you want to keep.

The password hashes are not in that export, and no API returns them. Auth0 hands them over through a support case, with conditions: see Export Password Hashes and MFA Secrets for what Auth0 asks for (a PGP key, written authorisation, a signed form) and note that Auth0 says not every request qualifies and gives no delivery date. Start this first: it is the step with the longest lead time, and the one you do not control.

Auth0's page does not describe the layout of the file you get. Whatever it is, reduce it to one JSON object that maps each user's email address to their hash, and check that every hash is bcrypt — it starts with $2a$, $2b$ or $2y$:

{
  "ada@example.com": "$2b$10$pl0VVKxZkNvtC9ttGwsT8.vPL8qWeEevw4a6FraeqQR8GQ6mT63NG"
}

If the hashes are not bcrypt, stop here and write to us before importing: this guide covers bcrypt only.

If you cannot get the hashes at all, you can still migrate: import the users without them, and they sign in with a one-time code or set a new password through recovery (see Users without a password).

2. One user, by hand

Creating a user with a hash is POST /v1/gate/identities with hashed_password in place of password:

curl -X POST https://api.vernesoft.com/v1/gate/identities \
  -H 'Authorization: Bearer vrn_9f8a7...' \
  -H 'Content-Type: application/json' \
  -d '{
    "schema_id": "user",
    "traits": { "email": "ada@example.com" },
    "credentials": {
      "password": { "config": { "hashed_password": "$2b$10$pl0VVKxZkNvtC9ttGwsT8.vPL8qWeEevw4a6FraeqQR8GQ6mT63NG" } }
    },
    "email_verified": true,
    "metadata_public": { "auth0_user_id": "auth0|64f1c0ffee", "plan": "pro" }
  }'

The answer is 201 with the new identity. From then on the user signs in with their Auth0 password, exactly as in Completing a login; a wrong password is refused as for anyone else. Try it with a test account of your own before you import everyone.

3. Import everyone

The script reads the Auth0 export and hashes.json, creates each user, and writes id-map.json (Auth0 user_id → Gate identity id) and failed.json.

npm install @verne-software/sdk
VERNE_API_KEY=vrn_9f8a7... node import-users.mjs users.json hashes.json
// node import-users.mjs users.json hashes.json
//
// users.json  — Auth0 bulk user export (newline-delimited JSON)
// hashes.json — { "<email>": "<bcrypt hash>" }, built from the file Auth0 support sends
import fs from 'node:fs';
import readline from 'node:readline';
import { Verne, VerneAPIError } from '@verne-software/sdk';

const verne = new Verne({
  apiKey: process.env.VERNE_API_KEY,
});

// Auth0 identity provider -> the Gate provider id the user will sign in with.
const PROVIDERS = {
  'google-oauth2': 'google',
  github: 'github',
};

const [usersFile, hashesFile] = process.argv.slice(2);
const hashes = hashesFile ? JSON.parse(fs.readFileSync(hashesFile, 'utf8')) : {};

function toGate(user) {
  const credentials = {};

  const hash = hashes[user.email];
  if (hash && user.identities.some((i) => i.provider === 'auth0')) {
    credentials.password = { config: { hashed_password: hash } };
  }

  const social = user.identities
    .filter((i) => PROVIDERS[i.provider])
    .map((i) => ({ provider: PROVIDERS[i.provider], subject: String(i.user_id) }));
  if (social.length > 0) {
    credentials.oidc = { config: { providers: social } };
  }

  return {
    schema_id: 'user',
    traits: { email: user.email },
    ...(Object.keys(credentials).length > 0 ? { credentials } : {}),
    // Auth0 confirmed this address: Gate keeps it confirmed. Anything else stays unconfirmed.
    ...(user.email_verified === true ? { email_verified: true } : {}),
    metadata_public: { ...user.user_metadata, auth0_user_id: user.user_id },
  };
}

async function create(body) {
  for (;;) {
    try {
      return await verne.gate.identities.create(body);
    } catch (err) {
      // The identity endpoints take 60 calls a minute per account: wait and go on.
      if (err instanceof VerneAPIError && err.status === 429) {
        await new Promise((r) => setTimeout(r, (err.retryAfter ?? 1) * 1000));
        continue;
      }
      throw err;
    }
  }
}

const idMap = {}; // Auth0 user_id -> Gate identity id
const failed = [];

const lines = readline.createInterface({ input: fs.createReadStream(usersFile) });
for await (const line of lines) {
  if (!line.trim()) continue;
  const user = JSON.parse(line);
  if (!user.email) {
    failed.push({ user_id: user.user_id, reason: 'no email address' });
    continue;
  }
  try {
    const identity = await create(toGate(user));
    idMap[user.user_id] = identity.id;
  } catch (err) {
    const reason = err instanceof VerneAPIError ? `${err.status} ${err.code}: ${err.message}` : String(err);
    failed.push({ user_id: user.user_id, reason });
  }
}

fs.writeFileSync('id-map.json', JSON.stringify(idMap, null, 2));
fs.writeFileSync('failed.json', JSON.stringify(failed, null, 2));
console.log(`imported ${Object.keys(idMap).length}, failed ${failed.length}`);

Things to know before you run it on everyone:

  • It takes time. The identity endpoints accept 60 calls a minute per account, and the script makes one call per user: about 17 minutes for 1,000 users, under 3 hours for 10,000. It waits out each 429 and carries on. Other calls to the identity endpoints during the import share the same 60.
  • It is plain JavaScript on purpose. In SDK 1.4.1 the TypeScript type of identities.create() lists only a plain password; hashed_password, oidc, metadata_public and email_verified are sent and accepted, but in TypeScript you have to cast the body.
  • A re-run does not duplicate anyone. A user who is already there is refused with 409 conflict and lands in failed.json; the users who were created the first time are not touched. A re-run writes a new id-map.json without them, so copy the first one somewhere safe.
  • Imported users are not active users. They count towards your plan's active users only once they sign in.

Social-login users

For a user who signed in to Auth0 with Google or GitHub, Auth0's export records the provider and that provider's id for the account in identities — { "provider": "google-oauth2", "user_id": "1098…" }. The script imports them as a linked social account, and when the user signs in with Google through Gate, Gate finds the imported user instead of creating a new one.

That only works if the provider gives Gate the same id it gave Auth0. Google and GitHub identify the account itself, so they do. Some providers give each application its own id for the same person (Facebook's app-scoped ids are the well-known case); for those the id in Auth0's export will not match, and the social sign-in will not find the imported user: Gate refuses it when the provider reports the same address (message 1010016, an account with this address already exists), and creates a new user when the address differs. Leave such providers out of PROVIDERS and treat those users as users without a password. Whatever the provider, sign in once with a test account of each before the cut-over.

The provider has to be switched on for your project: see Social Auth for the toggle, the return URL and the code exchange.

Users without a password

A user imported with no hash — a passwordless Auth0 user, a social user whose id will not match, or everyone if Auth0 did not give you the hashes — has no password in Gate. Two ways in, both through your own sign-in page:

Confirmed addresses

The script carries Auth0's email_verified over: a user whose address Auth0 had confirmed is created with "email_verified": true, and Gate stores the address as confirmed ("verified": true in the response). It is your word that the address was confirmed — Gate sends nobody a mail to check it. Gate confirms only the address of the user being created, so the flag cannot be aimed at anyone else.

A user Auth0 had not confirmed starts unconfirmed, as email_verified: false or no flag does. They confirm the address through the verification flow, and a sign-in with a mailed one-time code confirms it too. This matters if you use Automatic Deletion: the rule Delete unconfirmed accounts counts days from when the account was created, so switched on after an import it would warn those users and then delete the ones who did not confirm. Confirmed imports are never concerned. Before you switch the rule on, read its preview (matching_now): it should count only the users who were unconfirmed in Auth0, and you can leave the rule off until they have had time to confirm.

Roles and organizations

Gate has organizations, roles and permissions: a role is a named set of permissions inside one organization, and a user holds one role per organization.

  • Auth0 Organizations map one to one: each becomes a Gate organization, each member keeps their role in it.
  • Auth0 roles without organizations (role-based access control for the whole tenant) have no direct twin, because a Gate role always belongs to an organization. If your application has no customer organizations, create one organization for the application and put the roles there.
  • Names. A Gate role name is lowercase letters, digits, _ and - — Billing Manager becomes billing-manager. Permissions are lowercase segments joined by :, so Auth0's read:invoices works as it is.

Read your organizations, their members and the members' roles, and the roles' permissions from Auth0's Management API, and write them into one file in this shape. Then, with id-map.json from the user import next to it:

// node import-orgs.mjs orgs.json
//
// orgs.json   — what you read from Auth0's Management API, in this shape:
//   [{ "name": "Acme",
//      "roles": [{ "name": "admin", "permissions": ["invoices:read", "invoices:write"] }],
//      "members": [{ "auth0_user_id": "auth0|64f…", "role": "admin" }] }]
// id-map.json — written by import-users.mjs
import fs from 'node:fs';
import { Verne } from '@verne-software/sdk';

const verne = new Verne({
  apiKey: process.env.VERNE_API_KEY,
});
const orgs = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const idMap = JSON.parse(fs.readFileSync('id-map.json', 'utf8'));

for (const org of orgs) {
  const created = await verne.gate.organizations.create({ name: org.name });

  const roleIds = {};
  for (const role of org.roles) {
    const r = await verne.gate.organizations.roles.create(created.id, {
      name: role.name,
      permissions: role.permissions,
    });
    roleIds[role.name] = r.id;
  }

  for (const member of org.members) {
    const identityId = idMap[member.auth0_user_id];
    if (!identityId) {
      console.warn(`${org.name}: ${member.auth0_user_id} was not imported, skipped`);
      continue;
    }
    await verne.gate.organizations.members.add(created.id, {
      identity_id: identityId,
      role_id: roleIds[member.role],
    });
  }
  console.log(`${org.name}: ${org.roles.length} roles, ${org.members.length} members`);
}

Afterwards, POST /v1/gate/authorize answers may this user do this in this organization?, and the user's organizations and roles appear in the session check and the signed token.

Your application

Auth0's SDKs run the login for you and hand your API a JWT. With Gate, your backend runs the flows and keeps an opaque session token:

  • Sign-up, sign-in, the session check and sign-out are in Add sign-in to a Next.js app with Nautilus and Add sign-in to a Laravel app with Nautilus; the same calls exist in the Python and Rust SDKs.
  • Where something verified Auth0's JWTs — an API gateway, a serverless function, Supabase — Gate issues a signed token for a signed-in user, verifiable against your tenant's public keys.
  • Your own tables that reference Auth0's user_id need the new id: id-map.json has every pair, and metadata_public.auth0_user_id keeps the old id on the user.

Cut-over plan

  1. Request the password hashes from Auth0. First, because Auth0 gives no date.
  2. Rehearse. Import a handful of real users of each kind — password, Google, GitHub, no password — into Gate, sign in as each, then delete them (DELETE /v1/gate/identities/{id}).
  3. Ship the Gate version of your application behind a switch, tested against the rehearsal users.
  4. Freeze. Close sign-up and password changes in the Auth0 application for the window. A hash reflects the password at the time of the export: a user who changes their password after it gets the old one in Gate.
  5. Export and import. Run the bulk export, build hashes.json from the hash file, run import-users.mjs, check failed.json, run import-orgs.mjs.
  6. Switch. Turn the application over to Gate. Every user signs in once; the password they had works.
  7. Mop up. The users in failed.json, and anyone whose password does not work, go through recovery.
  8. Keep Auth0 until you are sure, read-only. Users who sign up in Gate after the switch exist only in Gate, so going back gets harder every day; decide in advance how long the way back stays open.

And if you leave Nautilus later

Moving in is one direction. If you later move off Nautilus, there is an export of your users: write to support and, within 7 days, you get one file encrypted to your own key with every user, their password hashes, social links, metadata, organizations and roles. It is a request to a person, not a button or an API call. The file maps onto Ory Kratos, and the same page shows how to turn it into an Auth0 bulk import.


Sign-in and users is part of Nautilus by Verne Software, together with webhooks, scheduled jobs and Telegram sign-in, in one plan priced by active users and hosted in France. Create a free account or see the plans.