Skip to content

Reconciler Customer & Auth

Failed invite acceptance leaves an orphaned auth identity

An invitee opens the link, sets a password, clicks accept, and something goes wrong on the last step: a duplicate email, a role conflict, a database hiccup. Medusa rolls the invite back to pending like nothing happened. But the invitee tries again and gets a flat 401, identity with that email already exists. Nothing about the invite looks different. The account that was half created during the first attempt is still sitting there, and it is now the only thing standing between the invitee and a working login. Here is why Medusa leaves that row behind and a script that finds and repairs it without touching anyone's real account.

Python and Node.js Medusa Admin API + Auth Module Guarded delete, dry run by default
Juggling communication devices
Photo by Chu CHU on Unsplash
The short answer

In Medusa v2, accepting an invite is two separate calls. The client first calls POST /auth/user/{provider}/register, which creates an AuthIdentity row for that email immediately and returns a registration JWT. Only after that does the client call POST /admin/invites/accept, which runs acceptInviteWorkflow: it validates the token, runs createUsersWorkflow as a step, and only then runs setAuthAppMetadataStep, deleteInvitesStep, and emitEventStep in parallel to link the identity to the new user. If user creation or that parallel step throws, Medusa's workflow engine compensates the invite back to pending, but the AuthIdentity was created in the earlier, separate register call, outside this workflow's transaction boundary, with no compensation step of its own. It is never rolled back or deleted, so a retry hits an identity that already exists. Detection has to run server-side with container.resolve(Modules.AUTH), since there is no public admin route that lists AuthIdentity rows. Full code and tests below.

The problem in plain words

Medusa splits invite acceptance into two calls on purpose, because the client needs a JWT from the auth provider before it can call the admin endpoint that actually creates the user. The first call, POST /auth/user/{provider}/register, is where the AuthIdentity row gets created. It happens fast, and it happens outside anything that later fails or rolls back.

The second call, POST /admin/invites/accept, carries that same auth_identity_id as input into acceptInviteWorkflow. That workflow does the real work: check the invite token is valid, run createUsersWorkflow to make the actual User row, then link the two together and mark the invite accepted. If anything in that second half throws, for example the email collides with an existing user, a role does not exist, or the database has a bad moment, Medusa's workflow engine steps in and compensates. The invite goes back to pending, exactly as documented. But the compensation only knows about steps inside this workflow. The AuthIdentity was never created by a step in acceptInviteWorkflow, it was created a call earlier, by a completely different route. There is nothing to compensate, so nothing happens to it. It just stays.

POST /auth/user/{provider} /register creates AuthIdentity AuthIdentity row exists au_..., outside any workflow POST /admin/invites/accept acceptInviteWorkflow runs createUsersWorkflow throws invite compensated to pending AuthIdentity orphaned no compensation step for it
The register call and the workflow that can fail sit on different sides of the transaction boundary. Compensation rolls the invite back but has nothing to say about the AuthIdentity it never created.

Why it happens

This comes down to where the transaction boundary sits, not a missing check inside any single step:

This is a common source of confusion because the invite looks completely normal afterward. It shows up in the admin as pending, same as any invite waiting to be accepted, with its token still valid if it has not expired. Nothing on the invite record hints that an earlier attempt already left a live AuthIdentity behind. The invitee just keeps hitting the same wall no matter how many times they retry, because retrying calls register again, and register always finds the row from the first attempt. See the citations at the end for the exact issues and reference docs.

The key insight

AuthIdentity is an internal Auth Module data model with no public admin listing route, so you cannot just query for orphans. But you can cross-reference three things you already have access to: pending invites from userModuleService.listInvites({accepted: false}), matching identities from authModuleService.listAuthIdentities filtered by provider_identities.entity_id, and a check that userModuleService.listUsers({email}) comes back empty for that same email. When all three line up, the identity is provably orphaned, and it is safe to remove with deleteAuthIdentities. When a real user already exists on that email, stop and flag it instead, because deleting there would break a working login.

The fix, as a flow

We never touch the accept flow itself. We run a separate reconciliation job that lists pending invites, checks each one for a matching orphaned identity, and only removes the identity when there is no linked user, leaving the invite itself untouched so the invitee can simply try again.

List pending invites Find matching AuthIdentity by entity_id === email User already exists for email? yes, ambiguous Flag, do not touch a working login exists no Invite already expired? no resend_invite also fresh token yes delete Auth Identity
The invite is never touched unless it has already expired. Only a confirmed, userless AuthIdentity gets deleted, clearing the way for the invitee to register again.

Build it step by step

1

Get an admin session, and know this runs server-side

The Admin API gives you invites, but not AuthIdentity rows, that model is internal to the Auth Module and has no public listing route. So this script authenticates against the Admin API to read invites, and assumes it also has access to a Medusa server context (a custom script or subscriber) where container.resolve(Modules.AUTH) and container.resolve(Modules.USER) are available for the identity and user lookups. Default to DRY_RUN=true so nothing is deleted until you have reviewed the list.

setup (shell)
pip install requests

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true"   # start safe, only reports the orphans it finds
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true"   // start safe, only reports the orphans it finds
2

Authenticate against the Admin API

Exchange the admin email and password for a JWT at POST /auth/user/emailpass, then send it as Authorization: Bearer <token> on every following call. This token is only used to read invites and, if needed, to trigger a resend.

step2.py
import os, requests

BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]

def get_token():
    r = requests.post(
        f"{BASE_URL}/auth/user/emailpass",
        json={"email": EMAIL, "password": PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]
step2.js
const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL;
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD;

async function getToken() {
  const res = await fetch(`${BASE_URL}/auth/user/emailpass`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
  });
  if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
  const body = await res.json();
  return body.token;
}
3

List pending invites, then the auth identities and users to cross-check

Ask for invites with accepted:false, then feed each invite email into the identity and user lookups. In a real deployment the identity and user lookups run through container.resolve(Modules.AUTH) and container.resolve(Modules.USER) inside a Medusa script or subscriber, not through a public REST route, since none exists for AuthIdentity.

step3.py
def list_pending_invites(token):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/invites",
        params={"fields": "id,email,accepted,expires_at,token"},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    invites = r.json()["invites"]
    return [i for i in invites if i.get("accepted") is False]


# Server-side only, run inside a Medusa script or subscriber:
#
# const authModuleService = container.resolve(Modules.AUTH)
# const userModuleService = container.resolve(Modules.USER)
#
# const authIdentities = await authModuleService.listAuthIdentities(
#   {},
#   { relations: ["provider_identities"] }
# )
# const users = await userModuleService.listUsers({ email: invite.email })
step3.js
async function listPendingInvites(token) {
  const res = await fetch(`${BASE_URL}/admin/invites?fields=id,email,accepted,expires_at,token`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Medusa invites ${res.status}`);
  const body = await res.json();
  return body.invites.filter((i) => i.accepted === false);
}

// Server-side only, run inside a Medusa script or subscriber:
//
// const authModuleService = container.resolve(Modules.AUTH)
// const userModuleService = container.resolve(Modules.USER)
//
// const authIdentities = await authModuleService.listAuthIdentities(
//   {},
//   { relations: ["provider_identities"] }
// )
// const users = await userModuleService.listUsers({ email: invite.email })
4

Decide, with one pure function

Keep the decision separate from every I/O call. It takes the pending invites, the auth identities (already shaped down to id, entityId, and providerId), the users, and a fixed clock, and returns a plain decision per orphan candidate. Only a pending invite with a matching identity and no linked user gets a delete decision. A matching identity next to an existing user is flagged instead of touched. An orphan tied to an expired invite gets a resend decision alongside the delete.

decide.py
def find_orphaned_auth_identities(invites, auth_identities, users, now):
    user_emails = {u["email"].strip().lower() for u in users if u.get("email")}
    decisions = []

    for invite in invites:
        if invite.get("accepted") is not False:
            continue
        email = invite["email"].strip().lower()

        match = next(
            (ai for ai in auth_identities if ai.get("entityId", "").strip().lower() == email),
            None,
        )
        if match is None:
            continue

        if email in user_emails:
            decisions.append({
                "inviteId": invite["id"], "email": invite["email"],
                "authIdentityId": match["id"], "action": "flag_ambiguous",
            })
            continue

        expires_at = invite.get("expires_at")
        if expires_at is not None and expires_at < now:
            decisions.append({
                "inviteId": invite["id"], "email": invite["email"],
                "authIdentityId": match["id"], "action": "resend_invite",
            })
        else:
            decisions.append({
                "inviteId": invite["id"], "email": invite["email"],
                "authIdentityId": match["id"], "action": "delete_auth_identity",
            })

    return decisions
decide.js
export function findOrphanedAuthIdentities(invites, authIdentities, users, now) {
  const userEmails = new Set(
    users.filter((u) => u.email).map((u) => u.email.trim().toLowerCase())
  );
  const decisions = [];

  for (const invite of invites) {
    if (invite.accepted !== false) continue;
    const email = invite.email.trim().toLowerCase();

    const match = authIdentities.find(
      (ai) => (ai.entityId || "").trim().toLowerCase() === email
    );
    if (!match) continue;

    if (userEmails.has(email)) {
      decisions.push({ inviteId: invite.id, email: invite.email, authIdentityId: match.id, action: "flag_ambiguous" });
      continue;
    }

    const expiresAt = invite.expires_at ? new Date(invite.expires_at) : null;
    if (expiresAt !== null && expiresAt < now) {
      decisions.push({ inviteId: invite.id, email: invite.email, authIdentityId: match.id, action: "resend_invite" });
    } else {
      decisions.push({ inviteId: invite.id, email: invite.email, authIdentityId: match.id, action: "delete_auth_identity" });
    }
  }

  return decisions;
}
5

Apply only what the decision says, never more

For a delete_auth_identity decision, call authModuleService.deleteAuthIdentities([authIdentity.id]) and leave the invite completely alone, still pending, token intact if it has not expired, so the invitee can register and accept again. For resend_invite, call POST /admin/invites/{id}/resend to reissue a fresh token, in addition to the delete when the identity is also orphaned. For flag_ambiguous, only log it. Never call delete on that path.

apply.py
def resend_invite(token, invite_id):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.post(
        f"{BASE_URL}/admin/invites/{invite_id}/resend",
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()


# Server-side only, run inside a Medusa script or subscriber:
#
# await authModuleService.deleteAuthIdentities([authIdentity.id])
apply.js
async function resendInvite(token, inviteId) {
  const res = await fetch(`${BASE_URL}/admin/invites/${inviteId}/resend`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Medusa resend ${res.status}`);
}

// Server-side only, run inside a Medusa script or subscriber:
//
// await authModuleService.deleteAuthIdentities([authIdentity.id])
6

Wire it together with a dry run guard

The loop lists pending invites, resolves each one against auth identities and users, runs them all through the pure decision function, then applies each decision only when DRY_RUN is off. Read the dry run output first. Anything marked flag_ambiguous needs a human to look at it, not a script to guess.

Run it safe

Never delete an AuthIdentity when a matching User already exists for that email. That row is a working login, and the resemblance to an orphan is exactly what makes this dangerous to automate blindly. Only delete_auth_identity decisions should ever call deleteAuthIdentities, and only after you have confirmed there is no linked user.

The full code

Here is the complete script in one file for each language. It authenticates, lists pending invites, and runs the pure decision function against auth identity and user data you supply (from your Medusa server context), gated by DRY_RUN. The direct authModuleService and userModuleService calls are commented inline because they only run inside a Medusa server process, not over the Admin REST API.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 88 Medusa fixes, free and open source.
repair_orphaned_identity.py
"""Find and repair an AuthIdentity orphaned by a failed Medusa v2 invite accept.

POST /auth/user/{provider}/register creates the AuthIdentity before
POST /admin/invites/accept ever runs acceptInviteWorkflow. If that workflow
fails, Medusa compensates the invite back to pending, but the AuthIdentity has
no compensation step of its own and is left behind, blocking every retry with
"Identity with email already exists". This lists pending invites over the
Admin API, cross-checks them against auth identities and users you supply
(read server-side with container.resolve(Modules.AUTH) and Modules.USER), and
only ever deletes an identity when no user is linked to that email. DRY_RUN=true
only reports what it would do. Safe to run again and again.
"""
import os
import logging
import datetime
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("repair_orphaned_identity")

BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ.get("MEDUSA_ADMIN_EMAIL", "admin@example.com")
PASSWORD = os.environ.get("MEDUSA_ADMIN_PASSWORD", "supersecret")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def get_token():
    r = requests.post(
        f"{BASE_URL}/auth/user/emailpass",
        json={"email": EMAIL, "password": PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]


def list_pending_invites(token):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/invites",
        params={"fields": "id,email,accepted,expires_at,token"},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    invites = r.json()["invites"]
    return [i for i in invites if i.get("accepted") is False]


def resend_invite(token, invite_id):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.post(
        f"{BASE_URL}/admin/invites/{invite_id}/resend",
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()


def find_orphaned_auth_identities(invites, auth_identities, users, now):
    """Pure: no I/O. invites/authIdentities/users are plain lists, now is a datetime."""
    user_emails = {u["email"].strip().lower() for u in users if u.get("email")}
    decisions = []

    for invite in invites:
        if invite.get("accepted") is not False:
            continue
        email = invite["email"].strip().lower()

        match = next(
            (ai for ai in auth_identities if ai.get("entityId", "").strip().lower() == email),
            None,
        )
        if match is None:
            continue

        if email in user_emails:
            decisions.append({
                "inviteId": invite["id"], "email": invite["email"],
                "authIdentityId": match["id"], "action": "flag_ambiguous",
            })
            continue

        expires_at = invite.get("expires_at")
        if expires_at is not None and expires_at < now:
            decisions.append({
                "inviteId": invite["id"], "email": invite["email"],
                "authIdentityId": match["id"], "action": "resend_invite",
            })
        else:
            decisions.append({
                "inviteId": invite["id"], "email": invite["email"],
                "authIdentityId": match["id"], "action": "delete_auth_identity",
            })

    return decisions


def run():
    token = get_token()
    invites = list_pending_invites(token)

    # In a real deployment these two calls happen server-side, inside a Medusa
    # script or subscriber, using container.resolve(Modules.AUTH) and
    # container.resolve(Modules.USER). There is no public admin route for
    # AuthIdentity, so this is left as an injection point:
    #
    #   auth_identities = await authModuleService.listAuthIdentities(
    #       {}, {"relations": ["provider_identities"]}
    #   )
    #   users = await userModuleService.listUsers({})
    auth_identities = []
    users = []

    now = datetime.datetime.now(datetime.timezone.utc)
    decisions = find_orphaned_auth_identities(invites, auth_identities, users, now)

    for decision in decisions:
        if decision["action"] == "flag_ambiguous":
            log.warning(
                "Flagged: %s has both a pending invite and a user. Not touching AuthIdentity %s.",
                decision["email"], decision["authIdentityId"],
            )
            continue

        if decision["action"] == "resend_invite":
            log.warning(
                "Invite for %s expired with an orphaned identity. %s",
                decision["email"], "would resend and delete identity" if DRY_RUN else "resending and deleting identity",
            )
            if not DRY_RUN:
                resend_invite(token, decision["inviteId"])
                # await authModuleService.deleteAuthIdentities([decision["authIdentityId"]])
            continue

        log.info(
            "Orphaned AuthIdentity for %s. %s",
            decision["email"], "would delete" if DRY_RUN else "deleting",
        )
        if not DRY_RUN:
            pass
            # await authModuleService.deleteAuthIdentities([decision["authIdentityId"]])

    log.info("Done. %d decision(s) evaluated.", len(decisions))


if __name__ == "__main__":
    run()
repair-orphaned-identity.js
/**
 * Find and repair an AuthIdentity orphaned by a failed Medusa v2 invite accept.
 *
 * POST /auth/user/{provider}/register creates the AuthIdentity before
 * POST /admin/invites/accept ever runs acceptInviteWorkflow. If that workflow
 * fails, Medusa compensates the invite back to pending, but the AuthIdentity has
 * no compensation step of its own and is left behind, blocking every retry with
 * "Identity with email already exists". This lists pending invites over the
 * Admin API, cross-checks them against auth identities and users you supply
 * (read server-side with container.resolve(Modules.AUTH) and Modules.USER), and
 * only ever deletes an identity when no user is linked to that email. DRY_RUN=true
 * only reports what it would do. Safe to run again and again.
 */
import { pathToFileURL } from "node:url";

const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function findOrphanedAuthIdentities(invites, authIdentities, users, now) {
  // Pure: no I/O. invites/authIdentities/users are plain arrays, now is a Date.
  const userEmails = new Set(
    users.filter((u) => u.email).map((u) => u.email.trim().toLowerCase())
  );
  const decisions = [];

  for (const invite of invites) {
    if (invite.accepted !== false) continue;
    const email = invite.email.trim().toLowerCase();

    const match = authIdentities.find(
      (ai) => (ai.entityId || "").trim().toLowerCase() === email
    );
    if (!match) continue;

    if (userEmails.has(email)) {
      decisions.push({ inviteId: invite.id, email: invite.email, authIdentityId: match.id, action: "flag_ambiguous" });
      continue;
    }

    const expiresAt = invite.expires_at ? new Date(invite.expires_at) : null;
    if (expiresAt !== null && expiresAt < now) {
      decisions.push({ inviteId: invite.id, email: invite.email, authIdentityId: match.id, action: "resend_invite" });
    } else {
      decisions.push({ inviteId: invite.id, email: invite.email, authIdentityId: match.id, action: "delete_auth_identity" });
    }
  }

  return decisions;
}

async function getToken() {
  const res = await fetch(`${BASE_URL}/auth/user/emailpass`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
  });
  if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
  const body = await res.json();
  return body.token;
}

async function listPendingInvites(token) {
  const res = await fetch(`${BASE_URL}/admin/invites?fields=id,email,accepted,expires_at,token`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Medusa invites ${res.status}`);
  const body = await res.json();
  return body.invites.filter((i) => i.accepted === false);
}

async function resendInvite(token, inviteId) {
  const res = await fetch(`${BASE_URL}/admin/invites/${inviteId}/resend`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Medusa resend ${res.status}`);
}

export async function run() {
  const token = await getToken();
  const invites = await listPendingInvites(token);

  // In a real deployment these two calls happen server-side, inside a Medusa
  // script or subscriber, using container.resolve(Modules.AUTH) and
  // container.resolve(Modules.USER). There is no public admin route for
  // AuthIdentity, so this is left as an injection point:
  //
  //   const authModuleService = container.resolve(Modules.AUTH)
  //   const userModuleService = container.resolve(Modules.USER)
  //   const authIdentities = await authModuleService.listAuthIdentities(
  //     {}, { relations: ["provider_identities"] }
  //   )
  //   const users = await userModuleService.listUsers({})
  const authIdentities = [];
  const users = [];

  const decisions = findOrphanedAuthIdentities(invites, authIdentities, users, new Date());

  for (const decision of decisions) {
    if (decision.action === "flag_ambiguous") {
      console.warn(
        `Flagged: ${decision.email} has both a pending invite and a user. Not touching AuthIdentity ${decision.authIdentityId}.`
      );
      continue;
    }

    if (decision.action === "resend_invite") {
      console.warn(
        `Invite for ${decision.email} expired with an orphaned identity. ${DRY_RUN ? "would resend and delete identity" : "resending and deleting identity"}`
      );
      if (!DRY_RUN) {
        await resendInvite(token, decision.inviteId);
        // await authModuleService.deleteAuthIdentities([decision.authIdentityId])
      }
      continue;
    }

    console.log(`Orphaned AuthIdentity for ${decision.email}. ${DRY_RUN ? "would delete" : "deleting"}`);
    if (!DRY_RUN) {
      // await authModuleService.deleteAuthIdentities([decision.authIdentityId])
    }
  }

  console.log(`Done. ${decisions.length} decision(s) evaluated.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The function worth testing is findOrphanedAuthIdentities, since it decides which email gets deleted, flagged, or resent. It is pure, no network and no Medusa instance, just plain arrays and a fixed clock in, and a decision array out, so the tests feed in fixture invites, identities, and users and check the answer.

test_orphaned_identity.py
from datetime import datetime, timezone
from repair_orphaned_identity import find_orphaned_auth_identities

NOW = datetime(2026, 7, 10, tzinfo=timezone.utc)


def invite(**over):
    base = {"id": "invite_01", "email": "jane@example.com", "accepted": False,
            "expires_at": datetime(2026, 7, 20, tzinfo=timezone.utc)}
    base.update(over)
    return base


def identity(**over):
    base = {"id": "au_01", "entityId": "jane@example.com", "providerId": "emailpass"}
    base.update(over)
    return base


def test_no_decision_when_no_matching_identity():
    result = find_orphaned_auth_identities([invite()], [], [], NOW)
    assert result == []


def test_delete_when_pending_orphaned_and_not_expired():
    result = find_orphaned_auth_identities([invite()], [identity()], [], NOW)
    assert result == [{"inviteId": "invite_01", "email": "jane@example.com",
                        "authIdentityId": "au_01", "action": "delete_auth_identity"}]


def test_flag_ambiguous_when_user_already_exists():
    users = [{"id": "user_01", "email": "jane@example.com"}]
    result = find_orphaned_auth_identities([invite()], [identity()], users, NOW)
    assert result == [{"inviteId": "invite_01", "email": "jane@example.com",
                        "authIdentityId": "au_01", "action": "flag_ambiguous"}]


def test_resend_invite_when_expired():
    expired = invite(expires_at=datetime(2026, 7, 1, tzinfo=timezone.utc))
    result = find_orphaned_auth_identities([expired], [identity()], [], NOW)
    assert result == [{"inviteId": "invite_01", "email": "jane@example.com",
                        "authIdentityId": "au_01", "action": "resend_invite"}]


def test_skip_when_invite_already_accepted():
    accepted = invite(accepted=True)
    result = find_orphaned_auth_identities([accepted], [identity()], [], NOW)
    assert result == []


def test_user_check_wins_over_expired_invite():
    expired = invite(expires_at=datetime(2026, 7, 1, tzinfo=timezone.utc))
    users = [{"id": "user_01", "email": "jane@example.com"}]
    result = find_orphaned_auth_identities([expired], [identity()], users, NOW)
    assert result[0]["action"] == "flag_ambiguous"


def test_case_and_whitespace_are_normalized():
    messy_invite = invite(email="  Jane@Example.com  ")
    result = find_orphaned_auth_identities([messy_invite], [identity()], [], NOW)
    assert result[0]["action"] == "delete_auth_identity"
orphan.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findOrphanedAuthIdentities } from "./repair-orphaned-identity.js";

const NOW = new Date("2026-07-10T00:00:00Z");

const invite = (over = {}) => ({
  id: "invite_01",
  email: "jane@example.com",
  accepted: false,
  expires_at: "2026-07-20T00:00:00Z",
  ...over,
});

const identity = (over = {}) => ({
  id: "au_01",
  entityId: "jane@example.com",
  providerId: "emailpass",
  ...over,
});

test("no decision when no matching identity", () => {
  const result = findOrphanedAuthIdentities([invite()], [], [], NOW);
  assert.deepEqual(result, []);
});

test("delete when pending, orphaned, and not expired", () => {
  const result = findOrphanedAuthIdentities([invite()], [identity()], [], NOW);
  assert.deepEqual(result, [
    { inviteId: "invite_01", email: "jane@example.com", authIdentityId: "au_01", action: "delete_auth_identity" },
  ]);
});

test("flag ambiguous when user already exists", () => {
  const users = [{ id: "user_01", email: "jane@example.com" }];
  const result = findOrphanedAuthIdentities([invite()], [identity()], users, NOW);
  assert.deepEqual(result, [
    { inviteId: "invite_01", email: "jane@example.com", authIdentityId: "au_01", action: "flag_ambiguous" },
  ]);
});

test("resend invite when expired", () => {
  const expired = invite({ expires_at: "2026-07-01T00:00:00Z" });
  const result = findOrphanedAuthIdentities([expired], [identity()], [], NOW);
  assert.deepEqual(result, [
    { inviteId: "invite_01", email: "jane@example.com", authIdentityId: "au_01", action: "resend_invite" },
  ]);
});

test("skip when invite already accepted", () => {
  const accepted = invite({ accepted: true });
  const result = findOrphanedAuthIdentities([accepted], [identity()], [], NOW);
  assert.deepEqual(result, []);
});

test("user check wins over expired invite", () => {
  const expired = invite({ expires_at: "2026-07-01T00:00:00Z" });
  const users = [{ id: "user_01", email: "jane@example.com" }];
  const result = findOrphanedAuthIdentities([expired], [identity()], users, NOW);
  assert.equal(result[0].action, "flag_ambiguous");
});

test("case and whitespace are normalized", () => {
  const messyInvite = invite({ email: "  Jane@Example.com  " });
  const result = findOrphanedAuthIdentities([messyInvite], [identity()], [], NOW);
  assert.equal(result[0].action, "delete_auth_identity");
});

Case studies

Duplicate email retry

The teammate who was invited twice by mistake

A team lead invited a new hire, then a second manager invited the same person again a day later without checking, thinking the first invite had gone stale. The new hire clicked the second link, registered, and hit a role conflict during accept because two invites for the same email collided inside createUsersWorkflow. The invite bounced back to pending, and the new hire's retry failed instantly with identity already exists.

Running the reconciler found exactly one pending invite for that email with a matching, userless AuthIdentity, a clean delete_auth_identity case. Deleting it left the still-pending invite untouched, and the new hire registered and accepted on the very next attempt, no second invite required.

Expired token

The contractor whose invite sat too long

A contractor was invited, started the accept flow, hit a transient database error on the last step, and then got busy and did not retry for two weeks. By the time they came back, the invite's expires_at had passed, and the orphaned AuthIdentity from that first attempt was still sitting there blocking a fresh register.

The reconciler's decision for that row was resend_invite, since the invite itself was expired on top of the orphan. The script reissued a fresh token with POST /admin/invites/{id}/resend and cleared the stale identity in the same pass, and the contractor accepted cleanly with the new link.

What good looks like

Run this reconciler on a schedule against your pending invites, and a failed accept stops being a dead end. The invite itself is never touched unless it has expired, and an identity is only ever deleted when there is provably no user attached to it. Anything ambiguous, a pending invite next to an existing user on the same email, gets reported for a human to look at instead of guessed at. Keep that boundary, and the script stays exactly as safe as it looks: a cleanup step, never a login-breaking one.

FAQ

Why does re-accepting a Medusa invite fail with Identity with email already exists?

Accepting an invite is two calls. The client first calls POST /auth/user/{provider}/register, which creates an AuthIdentity row for that email right away. Only then does it call POST /admin/invites/accept, which runs acceptInviteWorkflow and creates the user. If that second call fails, for example on a duplicate email or a role conflict, the workflow compensates the invite back to pending, but it has no compensation step for the AuthIdentity created in the earlier, separate register call. That row is left behind, so the next register attempt for the same email hits an identity that already exists.

How do I find an orphaned AuthIdentity from a failed invite accept?

There is no admin REST route that lists AuthIdentity rows, so run a server-side script with container.resolve. List pending invites with userModuleService.listInvites({accepted: false}), then for each invite email call authModuleService.listAuthIdentities with the provider_identities relation and filter for an entity_id that matches the invite email. If a matching AuthIdentity exists and userModuleService.listUsers({email}) returns nothing for that email, the identity is orphaned.

Is it safe to delete the orphaned AuthIdentity?

Yes, but only when the invite is still pending and no User row exists with that email. In that case authModuleService.deleteAuthIdentities removes only the stale identity and leaves the invite untouched, so the invitee can register and accept again cleanly. Never delete an AuthIdentity when a matching User already exists, since that would break a working login. If the invite has already expired, resend it instead of, or in addition to, deleting the identity.

Related field notes

Citations

On the problem:

  1. medusajs/medusa GitHub issue #9884: If invitation acceptance fails, the invitation expires and the auth identity is created. github.com/medusajs/medusa/issues/9884
  2. medusajs/medusa core-flows source: the accept-invite.ts workflow, showing createUsersWorkflow followed by the parallelized setAuthAppMetadataStep, deleteInvitesStep, and emitEventStep. github.com/medusajs/medusa/blob/develop/packages/core/core-flows/src/invite/workflows/accept-invite.ts
  3. medusajs/medusa GitHub issue #13256: Copy invite link gives error, Identity with email already exists. github.com/medusajs/medusa/issues/13256

On the solution:

  1. Medusa Documentation: AuthIdentity, Auth Module data model reference. docs.medusajs.com/resources/references/auth/models/AuthIdentity
  2. Medusa Documentation: Invite, User Module data model reference. docs.medusajs.com/resources/references/user/models/Invite
  3. Medusa Documentation: How to Use Authentication Routes. docs.medusajs.com/resources/commerce-modules/auth/authentication-route

Stuck on a tricky one?

If you have a problem in Medusa pricing, inventory, orders, promotions, or workflows that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this unblock a stuck invitee?

If this saved you from a confusing 401 that made no sense on a second attempt, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Medusa field notes