---
title: Account Migration
description: Migrate your users from a legacy authentication provider to SuperTokens.
sidebar:
  order: 2
---

The following guide shows you how to move users from your current authentication solution to **SuperTokens**.

---


## Overview

The process of migrating your accounts breaks down into two parts:

### Creating new users on the fly

To ensure a smooth migration process, with no downtime, you need to be able to directly create new users from the legacy sign up flow.
This is necessary since there is a time gap between when you export all your data for bulk import and when you go live with **SuperTokens**.

New users might get created in that interval through your legacy authentication provider.
Hence, you also need to create them in **SuperTokens** to keep the data in sync.

### Adding most of your users through a bulk import

After you have set in place the lazy migration process you can move on to adding most of your users.
This happens through the bulk import API.
The process is asynchronous and can work with large amounts of data.


## Before you start

This guide assumes that you have already integrated **SuperTokens** with your existing stack.
If you have not, please check the [Quickstart Guide](/quickstart) and explore all the supported [authentication methods](/authentication/overview).

Bulk import requires Core `10.0.0` or later and persistent database storage; the in-memory database does not support
these APIs. Before importing:

- create and configure every target tenant, role, recipe, and third-party provider referenced by the import;
- enable account linking before importing a user with multiple login methods, and test your linking policy with a
  representative export;
- decide how each legacy identity maps to a tenant and login method, and reject ambiguous or duplicate mappings; and
- take a restorable source export and define retry, reconciliation, rollback, and cutover procedures.

For email/password users, provide either a supported `passwordHash` with its `hashingAlgorithm`, or a
`plainTextPassword`, as defined by the [bulk-import request schema](/references/cdi/bulk-import/addbulkimportusers).
Prefer compatible bcrypt, Argon2, or Firebase `scrypt` hashes over plain-text passwords. Treat exports, password hashes,
MFA secrets, API keys, and access tokens as credentials: encrypt them in transit and at rest, restrict access, never put
them in logs or user metadata, and securely delete temporary copies after reconciliation.


## Steps

### 1. Update the legacy sign up flow

Modify the legacy sign up flow logic to also create new users in **SuperTokens**.
You can do this through the `Import User` endpoint that allows you to directly create accounts.
Call the endpoint from the authentication flow used by your legacy provider.

<ApiRequestSnippet operationId="importOneUserWithBulkImport" source="cdi" />

<Accordion>
<AccordionItem title="Auth0 Instructions">
:::caution[Unverified mapping pseudocode]
The following Action illustrates where a login-time direct import can run. Its identity fields, provider mapping, and
`getPasswordHash` placeholder have not been validated against a current Auth0 password/MFA support export. Adapt and
test it against a redacted export before use; do not deploy it as-is.
:::
:::warning
Auth0 does not expose password hashes or `TOTP` device information.
You will have to contact their support separately if you need this type of data.
:::

Create the Auth0 roles in SuperTokens before migrating users. The application endpoint must own an allowlisted mapping
from Auth0 organization/connection/provider identifiers to SuperTokens tenants and providers. Do not let Action input
select arbitrary tenant IDs or provider configuration.

##### 1. Access the Auth0 Dashboard
##### 2. From the navigation menu go to *Actions* > *Library*
##### 3. Click *Create Action* > *Create custom action*
##### 4. Specify a custom name for your action and then select *Login/Post Login* as the trigger
##### 5. Add `MIGRATION_ENDPOINT_URL` and `MIGRATION_ENDPOINT_TOKEN` Action secrets
##### 6. Paste the following code in the editor

`MIGRATION_ENDPOINT_TOKEN` must authorize only this migration endpoint. The endpoint must authenticate every request,
allow only the expected Auth0 tenant/issuer, rate-limit by credential and legacy user ID, enforce request-size limits,
and use `externalUserId` as an idempotency key. Keep the Core URL and Core API key only in your backend secret store. The
backend validates and maps the identity, retrieves any credential export through restricted storage, and then calls Core.

```typescript check=false reason="Requires application specific migration types"
exports.onExecutePostLogin = async (event, api) => {
  const migrationEndpoint = event.secrets.MIGRATION_ENDPOINT_URL;
  const migrationToken = event.secrets.MIGRATION_ENDPOINT_TOKEN;

  try {
    if (event.user.app_metadata?.migrated_to_supertokens) {
      return;
    }

    const response = await fetch(migrationEndpoint, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${migrationToken}`,
        "Content-Type": "application/json; charset=utf-8",
      },
      body: JSON.stringify({
        externalUserId: event.user.user_id,
        auth0OrganizationId: event.organization?.id,
        identities: event.user.identities?.map(({ provider, connection, user_id }) => ({
          provider,
          connection,
          userId: user_id,
        })),
      }),
    });

    const result = await response.json();

    if (response.ok && result.status === "OK") {
      api.user.setAppMetadata("migrated_to_supertokens", true);
      api.user.setAppMetadata("supertokens_user_id", result.userId);
    } else {
      console.error("Migration endpoint rejected the request");
    }
  } catch (error) {
    console.error("Migration endpoint request failed");
  }
};
```
</AccordionItem>
</Accordion>

<br />

:::info[If your application does not have a sign up process or if new users get created manually you can skip this step]
:::


### 2. Export the accounts from your legacy provider

Export the users from your legacy authentication provider and adjust the data to match the request body schema used in the [**`Add Users for Bulk Import`**](/references/cdi/bulk-import/addbulkimportusers) endpoint.

<Accordion>
<AccordionItem title="Auth0 Instructions">
:::warning
Auth0 does not export password hashes or `TOTP` device information.
You will have to contact their support and request them.
:::

#### 1. Create a management API application in Auth0
##### 1.1 Navigate to Auth0 Dashboard and the select `Applications` > `APIs`
##### 1.2 Select `Auth0 Management API`
##### 1.3 Go to `Machine to Machine Applications` tab
##### 1.4 Authorize your application or create a new one
##### 1.5 Grant only `read:users` and `read:users_app_metadata`
##### 1.6 Save your `Domain`, `Client ID`, and `Client Secret`

#### 2. Get the management API access token
You need a valid Management API Access Token to export users.
Use the following `cURL` command to get the token:
```bash
curl --request POST \
  --url 'https://YOUR_DOMAIN.auth0.com/oauth/token' \
  --header 'content-type: application/json' \
  --data '{
    "client_id": "YOUR_CLIENT_ID",
    "client_secret": "YOUR_CLIENT_SECRET",
    "audience": "https://YOUR_DOMAIN.auth0.com/api/v2/",
    "grant_type": "client_credentials"
  }'
```

#### 3. Create the export job
Use the `POST /api/v2/jobs/users-exports` endpoint to create a job that exports all users.

```bash
curl --request POST \
  --url 'https://YOUR_DOMAIN.auth0.com/api/v2/jobs/users-exports' \
  --header 'authorization: Bearer YOUR_MGMT_API_TOKEN' \
  --header 'content-type: application/json' \
  --data '{
    "format": "json",
    "fields": [
      {"name": "user_id"},
      {"name": "email"},
      {"name": "email_verified"},
      {"name": "name"},
      {"name": "nickname"},
      {"name": "picture"},
      {"name": "created_at"},
      {"name": "updated_at"},
      {"name": "identities"},
      {"name": "app_metadata"},
      {"name": "user_metadata"},
      {"name": "phone_number"},
      {"name": "phone_verified"}
    ]
  }'
```

#### 4. Check the export job status

Check if the export job has finalized with this request:

```bash
curl --request GET \
  --url 'https://YOUR_DOMAIN.auth0.com/api/v2/jobs/job_abc123xyz' \
  --header 'authorization: Bearer YOUR_MGMT_API_TOKEN'
```

#### 5. Download the export file

The previous request returns a `location` attribute in the response body if the export job has finalized.
Use it do access your data.

```bash
umask 077
cd /secure-migration-work # An encrypted, access-restricted filesystem
curl --fail --location --proto '=https' -o auth0_users.json.gz "LOCATION_URL_FROM_RESPONSE"
gzip --test auth0_users.json.gz
sha256sum auth0_users.json.gz > auth0_users.json.gz.sha256
age --recipient "<MIGRATION_ARCHIVE_RECIPIENT>" --output auth0_users.json.gz.age auth0_users.json.gz
```

Keep the compressed download on that encrypted filesystem, move the encrypted archive and checksum to restricted
migration storage, and verify that decryption succeeds. Auth0 exports NDJSON inside the gzip stream. Convert it without
replacing the retained compressed archive:

```bash
age --decrypt --identity /run/secrets/migration-archive-key auth0_users.json.gz.age \
  | gzip -dc \
  | jq -s '.' > /secure-migration-work/auth0_users_array.json
```

Keep the compressed export, encrypted copy, and checksum unchanged through transformation, import, failed-row retries,
and source-to-target reconciliation. Keep derived plaintext only on encrypted restricted storage and delete it after
each run. Delete all source-archive copies only after final reconciliation and rollback retention requirements are met;
use your storage system's verified deletion/lifecycle mechanism rather than assuming `rm` securely erases every medium.

#### 6. Transform the data to the SuperTokens format

:::warning
Auth0 does not expose password hashes or `TOTP` device information.
You will have to contact their support separately if you need this type of data.
:::

Create the Auth0 roles in SuperTokens before migrating users. This example assigns them to the default `public` tenant.

:::caution[Unverified mapping pseudocode]
The transformation below assumes application-specific Auth0 identity fields and an undefined `getPasswordHash` lookup.
The relationship between ordinary user-export rows and a separately requested password/MFA export is not established
here. Validate the mapping against a redacted current export and Core import validation before handling production data.
:::

```typescript check=false reason="Requires application specific migration types"
const fs = require("fs");

const auth0Users = JSON.parse(fs.readFileSync("auth0_users_array.json", "utf8"));

const superTokensUsers = auth0Users
  .map((auth0User) => {
    if (auth0User.app_metadata?.migrated_to_supertokens) {
      console.log(`User ${auth0User.user_id} already migrated`);
      return;
    }

    const userPayload = {
      externalUserId: auth0User.user_id,
      userMetadata: {
        auth0_user_id: auth0User.user_id,
        name: auth0User.name,
        nickname: auth0User.nickname,
        picture: auth0User.picture,
        auth0_user_metadata: auth0User.user_metadata,
        auth0_app_metadata: auth0User.app_metadata,
      },
      userRoles: (auth0User.app_metadata?.roles || []).map((role) => ({ role, tenantIds: ["public"] })),
      loginMethods: [],
    };

    const ThirdPartyProviders = ["google-oauth2", "facebook", "github", "apple"];

    auth0User.identities.forEach((identity, index) => {
      if (ThirdPartyProviders.includes(identity.provider)) {
        userPayload.loginMethods.push({
          recipeId: "thirdparty",
          thirdPartyId: mapProvider(identity.provider),
          thirdPartyUserId: identity.user_id,
          email: identity.profileData?.email ?? auth0User.email,
          isVerified: identity.profileData?.email_verified ?? auth0User.email_verified ?? false,
          isPrimary: index === 0,
          timeJoinedInMSSinceEpoch: new Date(auth0User.created_at).getTime(),
        });
      } else if (identity.provider === "auth0" || identity.provider === "Username-Password-Authentication") {
        // Auth0 does not export password hashes through the ordinary user export
        // You will have to contact their support and request them
        userPayload.loginMethods.push({
          recipeId: "emailpassword",
          email: identity.profileData?.email ?? auth0User.email,
          // Request the password hash from Auth0 and then implement the function to retrieve the values
          passwordHash: getPasswordHash(identity.profileData?.email),
          hashingAlgorithm: "bcrypt",
          isVerified: identity.profileData?.email_verified ?? auth0User.email_verified ?? false,
          isPrimary: index === 0,
          timeJoinedInMSSinceEpoch: new Date(auth0User.created_at).getTime(),
        });
      } else if (identity.provider === "sms") {
        userPayload.loginMethods.push({
          recipeId: "passwordless",
          phoneNumber: identity.profileData?.phone_number || auth0User.phone_number,
          isVerified: identity.profileData?.phone_verified ?? auth0User.phone_verified ?? false,
          isPrimary: index === 0,
          timeJoinedInMSSinceEpoch: new Date(auth0User.created_at).getTime(),
        });
      } else if (identity.provider === "email") {
        userPayload.loginMethods.push({
          recipeId: "passwordless",
          email: identity.profileData?.email || auth0User.email,
          isVerified: identity.profileData?.email_verified ?? auth0User.email_verified ?? false,
          isPrimary: index === 0,
          timeJoinedInMSSinceEpoch: new Date(auth0User.created_at).getTime(),
        });
      } else {
        throw new Error(`Unknown provider: ${identity.provider}`);
      }
    });

    return userPayload;
  })
  .filter(Boolean);

fs.writeFileSync("supertokens_users.json", JSON.stringify({ users: superTokensUsers }, null, 2));

function mapProvider(auth0Provider) {
  const mapping = {
    "google-oauth2": "google",
    facebook: "facebook",
    github: "github",
    apple: "apple",
  };
  return mapping[auth0Provider] || auth0Provider;
}

console.log(`Transformed ${superTokensUsers.length} users`);
```
</AccordionItem>
</Accordion>

### 3. Perform the bulk migration process

:::warning

If your application has a sign up process please make sure that you have completed the [**first step**](#1-update-the-legacy-sign-up-flow).
Otherwise, new accounts that get created after you have exported your users are not available in **SuperTokens**.

:::


#### 3.1 Add the accounts to import

Using the data that you have generated in the previous step, call the `Add Users for Bulk Import` endpoint.
This step stages the data that the background job imports later.

Keep in mind that the endpoint has a limit of **10000 users** per request.

<ApiRequestSnippet operationId="addBulkImportUsers" source="cdi" />

:::info[The Bulk Import Cron Job]

Every 5 minutes the **SuperTokens** core service runs a cron job that goes through the staged users and tries to import them.
If a user gets imported successfully it gets removed from the staged list.

:::


#### 3.2 Monitor the progress of the job

To determine if the import flow has processed all the users, call the [`Count Staged Users`](/references/cdi/bulk-import/countbulkimportusers) API.

Before doing that, first understand the different states in which a staged user can be.
During the import process, the user can have one of the following statuses:
- **NEW (not yet started)**: The import process has not yet picked up the user.
- **PROCESSING**: The import process has selected the user for import.
- **FAILED**: The import process has failed for that user.

If a user gets imported successfully it then gets removed from the staged list. Hence, no status exists for that state.

With this new information, get back to the `count users` endpoint.
The request counts the users that await import.
Pass a status filter as a query parameter to count only users in that state: `status=NEW`, `status=PROCESSING`, or `status=FAILED`.

<ApiRequestSnippet
  operationId="countBulkImportUsers"
  query={{ status: "PROCESSING" }}
  source="cdi"
/>

Given that information, to check if your import is complete do the following:
1. Call the `count users` API once without any filters. If the count is 0, then the import process is complete.
2. If the count is not 0, then check if you still have rows that are getting processed (`status=PROCESSING`) or if there are rows that the import job has not yet picked up (`status=NEW`)
3. If the only rows that remain are the ones with the `FAILED` status, then proceed to step `3.3`. There you can see how to debug those issues.


#### 3.3 Handle staged users that failed to import

Go through this step only if you have staged users that failed to import.
This can happen for a number of reasons. Some common ones:
- `Email` / `phoneNumber` already exists
- `externalUserId` is being already used by other user
- A primary user already exists for the email but with a different login method

If at the end of the previous step you have determined that you have staged users that failed to import, debug the issues with the [`Get Staged Users`](/references/cdi/bulk-import/getbulkimportusers) API. Filter the results with `status=FAILED`.

<ApiRequestSnippet
  operationId="getBulkImportUsers"
  query={{ status: "FAILED" }}
  source="cdi"
/>

The response includes the import error messages for each specific user.
Use them to determine what you need to correct in your import data.
Record the failed staged-row IDs and remove those exact rows before retrying. Verify that every requested ID appears in
`deletedIds` and that `invalidIds` is empty; otherwise, stop and reconcile the discrepancy.

<ApiRequestSnippet operationId="deleteBulkImportUsers" source="cdi" />

After removal, fix the source records and repeat step `3.1` only for that corrected data. Re-run the status checks and
reconcile every source identity to one successfully imported account. Never treat a zero count as sufficient if the
source export, removed IDs, corrected retries, and final accounts do not reconcile.


:::success[You have successfully migrated your accounts]


If all your data has imported then you can consider the account migration process complete.
Go on to the [session migration](/migration/session-migration) step to complete the entire migration flow.

:::


## See also

<CardGroup cols={3}>
  <Card title="Session migration" href="/migration/session-migration" />
  <Card title="Legacy migration" href="/migration/legacy/about" />
  <Card title="User management" href="/post-authentication/user-management/introduction" />
  <Card title="Account linking" href="/post-authentication/account-linking/introduction" />
</CardGroup>
