Skip to content
Esc
navigateopen⌘Jpreview
Dashboard
On this page

Step 3. Session Migration

This section explains how to migrate user sessions from your previous authentication provider to SuperTokens.

This process involves two steps.

  • Adding a new /migrate-session API to your backend which will create a new SuperTokens session
  • Calling the /migrate-session API on your frontend to create a new SuperTokens session and revoke your old session.

Flow

Session migration flow chart

Backend changes

Create a rate-limited backend API that exchanges a valid legacy access token for a SuperTokens session. The following example uses the APIs released in SuperTokens Node SDK 24.0.3.

import express from "express";
import SuperTokens from "supertokens-node";
import Session from "supertokens-node/recipe/session";

const app = express();
app.use(express.json());

interface VerifiedLegacyToken {
  issuer: string;
  subject: string;
  tenantId: string;
}

app.post("/migrate-session", migrationRateLimiter, async (req, res, next) => {
  const match = req.headers.authorization?.match(/^Bearer[ \t]+([^\s,]+)$/i);
  const idempotencyKey = req.header("Idempotency-Key");
  if (match === null || match === undefined || !isValidIdempotencyKey(idempotencyKey)) {
    res.status(401).send({ status: "INVALID_LEGACY_TOKEN" });
    return;
  }

  let verifiedToken: VerifiedLegacyToken;
  try {
    verifiedToken = await verifyLegacyAccessToken(match[1]);
  } catch {
    res.status(401).send({ status: "INVALID_LEGACY_TOKEN" });
    return;
  }

  try {
    const identity = getNamespacedLegacyIdentity(verifiedToken);
    await enforceVerifiedIdentityRateLimit(identity.identityKey);

    const mapping = await SuperTokens.getUserIdMapping({
      userId: identity.externalUserId,
      userIdType: "EXTERNAL",
    });
    if (mapping.status !== "OK") {
      res.status(401).send({ status: "INVALID_LEGACY_TOKEN" });
      return;
    }

    const recipeUserId = SuperTokens.convertToRecipeUserId(mapping.superTokensUserId);
    const result = await migrateSessionIdempotently(
      {
        idempotencyKey,
        identityKey: identity.identityKey,
        tenantId: verifiedToken.tenantId,
        recipeUserId: recipeUserId.getAsString(),
      },
      () => Session.createNewSession(req, res, verifiedToken.tenantId, recipeUserId),
    );
    if (result.status === "CONFLICT") {
      res.status(409).send({ status: "MIGRATION_CONFLICT" });
      return;
    }
    if (result.status === "IN_PROGRESS") {
      res.set("Retry-After", "1").status(409).send({ status: "MIGRATION_IN_PROGRESS" });
      return;
    }
    res.send({ status: result.status });
  } catch (error) {
    next(error);
  }
});

app.post("/confirm-session-migration", migrationRateLimiter, async (req, res, next) => {
  const idempotencyKey = req.body?.idempotencyKey;
  if (!isValidIdempotencyKey(idempotencyKey)) {
    res.status(400).send({ status: "INVALID_IDEMPOTENCY_KEY" });
    return;
  }

  try {
    const session = await Session.getSession(req, res);
    const confirmed = await confirmMigrationOutcome({
      idempotencyKey,
      tenantId: session.getTenantId(),
      recipeUserId: session.getRecipeUserId().getAsString(),
    });
    res.status(confirmed ? 200 : 409).send({ status: confirmed ? "CONFIRMED" : "IDENTITY_MISMATCH" });
  } catch (error) {
    next(error);
  }
});

function isValidIdempotencyKey(value: unknown): value is string {
  return typeof value === "string" && /^[A-Za-z0-9_-]{32,128}$/.test(value);
}

declare function migrationRateLimiter(req: express.Request, res: express.Response, next: express.NextFunction): void;
declare function enforceVerifiedIdentityRateLimit(identityKey: string): Promise<void>;

declare function getNamespacedLegacyIdentity(verifiedToken: VerifiedLegacyToken): {
  identityKey: string;
  externalUserId: string;
};

declare function migrateSessionIdempotently(
  input: {
    idempotencyKey: string;
    identityKey: string;
    tenantId: string;
    recipeUserId: string;
  },
  createSession: () => ReturnType<typeof Session.createNewSession>,
): Promise<{ status: "CREATED" | "RECOVERED" | "CONFLICT" | "IN_PROGRESS" }>;

declare function confirmMigrationOutcome(input: {
  idempotencyKey: string;
  tenantId: string;
  recipeUserId: string;
}): Promise<boolean>;

// Implement this contract with your provider's supported SDK or a JWT library configured for that provider.
declare function verifyLegacyAccessToken(accessToken: string): Promise<VerifiedLegacyToken>;

Configure CORS on the backend with the exact frontend origin, credentials: true, and the Authorization, Content-Type, and Idempotency-Key request headers explicitly allowed. Do not combine credentialed requests with Access-Control-Allow-Origin: *. The endpoint’s error handler should return a generic 401 for verification failures without exposing token-validation details.

Implement migrateSessionIdempotently with a distributed outcome store indexed uniquely by both the stable request key and the legacy identity scoped to its issuer and tenant. A completed outcome records the tenant, recipe user ID, and created session handle. A retry for the same key and identity must return or safely recover that logical outcome; if the original session was not delivered, revoke it before issuing a replacement. Reject a key bound to another identity, another key for an already migrated identity, and concurrent in-progress exchanges. Never store the raw legacy token. Keep completed outcomes at least until the legacy session and token can no longer be accepted. confirmMigrationOutcome must compare the current SuperTokens tenant and recipe user ID with that stored outcome and atomically mark it confirmed.

Frontend changes

On page load, obtain the legacy token and its stable request key. If a SuperTokens session exists, confirm that it matches the stored migration outcome before cleaning up the legacy session. Otherwise, perform the idempotent exchange, then confirm the identities. Never treat an HTTP success alone as proof that the current SuperTokens and legacy identities match. The example uses SuperTokens Web JS 0.16.0 and assumes that the SDK is initialized.

import axios from "axios";

import Session from "supertokens-web-js/recipe/session";

// Call this function on page load
async function migrateUserSessions() {
  const apiDomain = "...";
  const accessToken = await getAccessTokenFromOldProvider();
  if (accessToken === undefined) {
    return;
  }

  const idempotencyKey = await getOrCreateMigrationIdempotencyKey();

  if (!(await Session.doesSessionExist())) {
    await axios.post(
      `${apiDomain}/migrate-session`,
      {},
      {
        headers: {
          Authorization: `Bearer ${accessToken}`,
          "Idempotency-Key": idempotencyKey,
        },
        withCredentials: true,
      },
    );
  }

  if (!(await confirmMigratedIdentity(apiDomain, idempotencyKey))) {
    return;
  }

  await revokeSessionFromOldProvider();
  await clearMigrationIdempotencyKey();
}

async function confirmMigratedIdentity(apiDomain: string, idempotencyKey: string): Promise<boolean> {
  try {
    const response = await axios.post(
      `${apiDomain}/confirm-session-migration`,
      { idempotencyKey },
      { withCredentials: true },
    );
    return response.data.status === "CONFIRMED";
  } catch (error) {
    if (axios.isAxiosError(error) && error.response?.status === 409) {
      return false;
    }
    throw error;
  }
}

async function getAccessTokenFromOldProvider(): Promise<string | undefined> {
  // Return the provider's access token when its session exists, or undefined otherwise.
  return "...";
}

// Persist one random key for this specific legacy provider session until cleanup succeeds.
declare function getOrCreateMigrationIdempotencyKey(): Promise<string>;
declare function clearMigrationIdempotencyKey(): Promise<void>;

async function revokeSessionFromOldProvider() {
  // Revoke the session associated with the previous provider
}

See also

API reference

API schema and response details