Skip to main content

Migrate Users from an Existing User Store

Introduction

If you already run your own user store and want to move to the Mosaic User Service, you rarely want a flag-day cutover where every user is forced to reset their password or sign in again. The User Service supports a seamless migration built from two independent, complementary stages that you can run at the same time:

  • Session transfer moves a user who is currently signed in on your legacy system into the User Service silently, without a password and without a new sign-in.
  • Lazy credential migration moves every remaining user the first time they sign in with their email and password through your application.

Both stages converge on the same result: a User Service user identified by the user's email address, and an ordinary User Service session (the AX_REFRESH_TOKEN cookie the application uses to obtain access tokens). Because both are identified by email, a user migrated by one stage is recognized by the other, so no duplicate accounts are created and users are migrated exactly once.

Migration builds on the AxAuth standalone user store. Set that up first, as described in Configure AxAuth IDP for End-User Applications.

Before you start

  • AxAuth IDP configured for the application. The application must have an AxAuth IDP Connection, so it has a standalone user store to migrate into. See Configure AxAuth IDP for End-User Applications.
  • An authentication proxy in place. Migration writes the same AX_REFRESH_TOKEN session cookie a normal sign-in would, and the browser only sends that cookie to the token endpoint when the authentication endpoints share your application's top-level domain. See Set Up an Authentication Proxy.
  • A trusted backend you operate. Both stages call into a server-side component you build, referred to here as the migration proxy. It is trusted because it can mint User Service sessions and because it validates credentials against your legacy store, so it must never be reachable by untrusted callers. You can host the session-transfer endpoint and the credential-migration webhook in the same backend service.
  • A service account with the MIGRATE_END_USER_SESSIONS permission for session transfer (used in the first stage below).

Stage 1 - Transfer a live session

This stage targets users who are still signed in on your legacy system. When the application loads and finds no User Service session yet, it hands the migration proxy the user's live legacy credential. The proxy validates it, asks the User Service to provision the user and open a session, and returns that session to the browser as the AX_REFRESH_TOKEN cookie. The user is never prompted and no password is involved.

What the migration proxy must do

  1. Expose an endpoint your application calls, authenticated by the user's live legacy session (for example, the legacy access token sent in a header or cookie).
  2. Validate that legacy credential yourself using your existing authentication system, and derive the user's email (required). Optionally read the display name, phone number, and the user's subject identifier on the legacy provider.
  3. Call the migrateEndUserSession mutation on the User Service Management API, presenting a service account token that holds the MIGRATE_END_USER_SESSIONS permission.
  4. Return the resulting refreshToken to the browser as the AX_REFRESH_TOKEN cookie. Set it HttpOnly and Secure, and scope it to your application's registrable domain so the browser sends it to the token endpoint. See Set Up an Authentication Proxy for why the cookie domain matters.

The call is idempotent: if the user has already been migrated, migrateEndUserSession simply returns a fresh session, so it is safe to call on every application start and safe to retry.

The migrateEndUserSession operation

This is a secured operation on the User Service Management API (https://user.service.eu.axinom.net/graphql-management). It requires a valid service account token with the MIGRATE_END_USER_SESSIONS permission. The tenant and environment the session is created for are derived from that token.

Inputs

PropertyTypeRequiredDescription
applicationIdUUIDYesApplication the migrated session is created against.
emailStringYesEmail address of the migrated user. Used as the identity key in both the User Service and AxAuth.
name*StringNoDisplay name of the migrated user.
phoneStringNoPhone number of the migrated user.
legacySubjectIdStringNoThe user's subject identifier on your legacy provider. Stored for audit purposes only.

*Even though name is not a required field, it is recommended it is passed for a better user experience.

Response

PropertyTypeRequiredDescription
refreshTokenStringYesOpaque refresh token. Return it to the browser as the AX_REFRESH_TOKEN cookie and use it against the token endpoint.

Inside your migration endpoint, after you have validated the legacy session:

const MIGRATE_END_USER_SESSION = /* GraphQL */ `
mutation MigrateEndUserSession($input: MigrateEndUserSessionInput!) {
migrateEndUserSession(input: $input) {
refreshToken
}
}
`;

const response = await fetch(
"https://user.service.eu.axinom.net/graphql-management",
{
method: "POST",
headers: {
"content-type": "application/json",
// Service account token with the MIGRATE_END_USER_SESSIONS permission.
authorization: `Bearer ${serviceAccountToken}`,
},
body: JSON.stringify({
query: MIGRATE_END_USER_SESSION,
variables: {
input: {
applicationId,
email, // derived from the validated legacy credential
name, // optional
phone, // optional
legacySubjectId, // optional, audit only
},
},
}),
},
);

const { data } = await response.json();
const refreshToken = data.migrateEndUserSession.refreshToken;

// Hand the session to the browser as the AX_REFRESH_TOKEN cookie.
setCookie("AX_REFRESH_TOKEN", refreshToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
domain: ".example.com", // your application's registrable domain
});

On the application side, trigger the transfer only when there is no User Service session yet and the user still has a live legacy session:

// On application start, migrate a live legacy session exactly once.
if (!hasCookie("AX_REFRESH_TOKEN") && hasLiveLegacySession()) {
await fetch("https://id.example.com/migrate-legacy-session", {
method: "POST",
credentials: "include", // send the legacy credential to your migration proxy
});
}

Once the cookie is set, token retrieval and renewal are identical to any other User Service session: call getToken from @axinom/mosaic-user-auth, or request the token endpoint directly, exactly as described in How Authentication Works. Nothing about migration changes how the application uses the session afterwards.

Stage 2 - Lazy credential migration

Users who are not signed in on your legacy system (they signed out, cleared cookies, or are on a new device) are migrated the first time they sign in with their email and password through the application's normal AxAuth sign-in (see Sign in with AxAuth IDP). Until a user has been migrated, the AxAuth user store has no password on file for that email, so it calls your Migration Webhook to verify the submitted credentials against your legacy store. If the webhook confirms them, the user is migrated and signed in in the same step. Every later sign-in is a normal AxAuth sign-in with no webhook call.

The user's password never reaches Mosaic in a stored form: it is only forwarded to your own webhook, over a signed request, so that your legacy store can verify it. After a successful first sign-in, the password lives in the AxAuth user store and the webhook is no longer involved.

Configure the Migration Webhook

The Migration Webhook is configured on the AxAuth User Store, alongside the User Sign Up Webhook and Forgot Password Webhook. Open the User Store details station (Environment -> AxAuth Service Configuration -> User Stores -> your end-user store) and set the URL under Migration Webhook.

User Store Details station

user_store_details

Configuring the webhook produces a webhook secret. Copy it and store it securely; it is used to validate that incoming requests genuinely originate from the AxAuth Service, as described in Webhooks.

What the Migration Webhook must do

  1. Verify the request signature using the webhook secret, and reject requests whose timestamp is older than 120 seconds. The verifyWebhookRequestMiddleware helper in @axinom/mosaic-service-common does both.
  2. Check the email and password against your legacy user store.
  3. Respond with HTTP 2xx within 30 seconds. Set payload.valid to true only when the credentials are correct; the user is migrated and signed in only in that case. Optionally return a profile to populate the new account. Returning valid: false (or reporting errors) leaves the user unmigrated and fails the sign-in.

Request - the AxAuth Service sends a signed POST with this body:

{
"payload": {
"email": "user@example.com", // string
"password": "<plaintext>" // string
},
"message_type": "Migration Webhook",
"message_id": "<uuid>", // unique per call
"message_version": "1.0",
"timestamp": "<UTC ISO-8601>" // reject if older than 120s (default)
}

Response - return a 2xx with a JSON body:

{
"payload": {
"valid": true, // boolean, REQUIRED; only `true` migrates the user
"profile": {
// optional; used to populate the new account
"firstName": "Jane", // optional string
"lastName": "Doe", // optional string
"phoneNumber": "+1..." // optional string
}
},
"errors": null, // optional
"warnings": null, // optional
"message_version": "1.0" // optional
}

A minimal implementation, following the standard Mosaic webhook pattern:

import {
Dict,
generateWebhookResponse,
handleWebhookErrorMiddleware,
verifyWebhookRequestMiddleware,
WebhookRequestMessage,
} from "@axinom/mosaic-service-common";
import { Express, NextFunction, Request, Response } from "express";

export const setupMigrationWebhook = (app: Express, config: Config): void => {
app.post(
"/migration-webhook",

// Validate the request signature and reject stale requests.
verifyWebhookRequestMiddleware({
webhookSecret: config.migrationWebhookSecret,
payloadJsonSchema: MigrationWebhookRequestPayloadSchema,
}),
handleWebhookErrorMiddleware,

async (
req: Request<
Dict<string>,
Dict<string>,
WebhookRequestMessage<{ email: string; password: string }>
>,
res: Response,
next: NextFunction,
) => {
try {
const { email, password } = req.body.payload;

// Verify the credentials against your existing user store.
const legacyUser = await verifyLegacyCredentials(email, password);

const response = generateWebhookResponse({
payload: legacyUser
? {
valid: true,
profile: {
firstName: legacyUser.firstName,
lastName: legacyUser.lastName,
phoneNumber: legacyUser.phoneNumber,
},
}
: { valid: false },
});
res.status(200).send(response);
} catch (error) {
handleWebhookErrorMiddleware(error, req, res, next);
}
},
);
};

See Webhooks for the full request and response envelope, signature validation details, and how to expose your webhook during development.

See also

Was this page helpful?