Session Migration
Migrate user sessions from your previous authentication provider to SuperTokens using backend and frontend changes.
This guide shows you how to migrate user sessions from your previous authentication provider to SuperTokens.
Overview
To achieve a seamless transition process, you will also have to migrate the active sessions that use your previous authentication provider to SuperTokens. To do this you should create a new flow that will determine if an existing user session needs to be migrated, and call the migration API if necessary.
You can see a detailed illustration of the process below.
Steps
1. Add the session migration endpoint
Create a rate-limited backend endpoint 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.
2. Call the migration endpoint from your frontend app
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
}