Diagnostic Customer & Auth
Admin invite fails when the email already belongs to a customer
You send the invite through POST /admin/invites, the email arrives, and everything looks fine. Then the person you invited clicks the link, fills in a password, hits accept, and gets a 401 that says the identity with that email already exists. The invite is dead on arrival, and it will stay dead no matter how many times they retry, because the email was already used to register as a customer. Here is why Medusa cannot tell those two identities apart and a script that flags the collision before you ever send the invite.
In Medusa v2, both customer registration and admin invite acceptance ultimately call the same auth provider register method, for example POST /auth/user/emailpass/register, which looks up a single AuthIdentity row by email. When a customer already registered with that email, the identity already exists with app_metadata linking it to a customer actor, and the register flow has no actor_type awareness, so it cannot tell "this email already has a customer identity" apart from "this email needs a new admin identity." It short-circuits with a 401 Identity with email already exists instead of creating a separate identity for the admin actor type. POST /admin/invites succeeds and the email goes out, but the invitee's POST /admin/invites/accept fails at that same register step, forever, for that email. There is no safe API fix, only detection. Before sending an invite, check GET /admin/customers?email=...&fields=id,email,has_account: if has_account is true, the invite will fail at accept time and you should use a different email instead. Full code and tests below.
The problem in plain words
Medusa keeps authentication in one table, the AuthIdentity, and it is keyed by the auth provider and the email. A customer who registers through the storefront gets a row there, and Medusa attaches app_metadata that says this identity belongs to a customer actor. That is the whole account, one row, one actor link.
An admin invite is a separate concept on paper. You call POST /admin/invites with an email and a role, Medusa creates an invite record and emails a token. But accepting that invite still has to create a login, and Medusa creates admin logins the exact same way it creates customer logins: it calls the auth provider's register method for that email. The register method does one thing, look up the email, and if a row is already there, refuse. It never asks which actor type the existing row belongs to. So the moment a customer has already claimed an email, any later admin invite to that same email is set up to fail, and nothing about sending the invite warns you.
Why it happens
This is a gap in how Medusa v2's auth flows share one lookup, not a bug in any single endpoint:
- Both storefront customer registration and admin invite acceptance route through the same auth provider register method, for example
POST /auth/user/emailpass/register, which is keyed purely on email with noactor_typeparameter in the lookup. - An
AuthIdentityrow does carryapp_metadatathat links it to acustomeroruseractor, but the register method's own duplicate check never reads that field before deciding to refuse. POST /admin/inviteshas no awareness of this either. It creates the invite record and sends the email regardless of whether the target email already has a customer identity, so the failure is invisible until someone actually tries to accept.- This is documented directly in medusajs/medusa issue #12521, where an admin invitation fails specifically because the email is already used by a customer, and in issue #10607, which reports the same collision in both directions, a customer cannot register with an email an admin already owns, and vice versa, on the same authentication provider.
- The result is a support ticket that looks like a broken invite link, when the real cause is an email address doing double duty across two actor types that Medusa's auth module was not built to separate.
This is a common source of confusion because POST /admin/invites returns 200 and the email genuinely sends. Everything about the invite creation step looks successful. The failure only shows up one step later, on the invitee's machine, at accept time, which makes it look like a client-side or email problem rather than what it really is: an email that was never available to invite as an admin in the first place. See the citations at the end for the exact issues and docs.
You cannot ask Medusa's admin API directly whether an email already has a customer or admin AuthIdentity, because there is no exposed auth-identity or actor_type listing route. So detection has to use a reliable proxy: GET /admin/customers?email=...&fields=id,email,has_account. A has_account: true hit means that email owns a customer identity, and any admin invite to it is guaranteed to fail at accept time with the exact same 401. Check that before you call POST /admin/invites, not after the invitee complains.
The fix, as a flow
We do not try to make Medusa merge or delete identities, because there is no safe way to do that without risking a real customer's account. Instead we check the target email against customers, admin users, and pending invites before the invite is ever created, and block the invite outright when a collision is found.
Build it step by step
Get an admin session and the base URL
Point the script at your Medusa backend and an admin user with rights to read customers, users, and invites. Exchange the email and password for a JWT once, then send it as a Bearer token on every admin call. Keep everything in environment variables, never hardcoded, and default to DRY_RUN=true so the script only reports, it never sends an invite on your behalf.
pip install requests
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export TARGET_EMAIL="jane@example.com" # the email you want to invite as admin
export INVITE_ROLE="admin"
export DRY_RUN="true" # start safe, only reports the collision check
// 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 TARGET_EMAIL="jane@example.com" // the email you want to invite as admin
export INVITE_ROLE="admin"
export DRY_RUN="true" // start safe, only reports the collision check
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. Both languages do this the same way.
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"]
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;
}
Look up customers, admin users, and pending invites
There is no direct auth-identity or actor_type listing route, so we use has_account on /admin/customers as the reliable proxy signal. We also check /admin/users for an existing admin on that email, and /admin/invites for a pending, unaccepted invite already sitting there.
def find_customers(token, email):
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE_URL}/admin/customers",
params={"email": email, "fields": "id,email,has_account"},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["customers"]
def find_admin_users(token, email):
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE_URL}/admin/users",
params={"email": email, "fields": "id,email"},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["users"]
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"},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["invites"]
async function apiGet(token, path, params) {
const url = new URL(`${BASE_URL}${path}`);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${path} ${res.status}`);
return res.json();
}
async function findCustomers(token, email) {
const body = await apiGet(token, "/admin/customers", { email, fields: "id,email,has_account" });
return body.customers;
}
async function findAdminUsers(token, email) {
const body = await apiGet(token, "/admin/users", { email, fields: "id,email" });
return body.users;
}
async function listPendingInvites(token) {
const body = await apiGet(token, "/admin/invites", { fields: "id,email,accepted,expires_at" });
return body.invites;
}
Decide, with one pure function
Keep the decision in a function with no network calls. It normalizes the target email, then checks it against the three lists in order, a matching customer with has_account: true first, then a matching admin user, then a pending, unaccepted invite. Anything found there means an admin invite to that email is guaranteed to fail at accept time, so we say so and stop before creation.
def will_invite_collide(target_email, customers, admin_users, pending_invites):
email = target_email.strip().lower()
for customer in customers:
if customer.get("email", "").strip().lower() == email and customer.get("has_account") is True:
return {"safe": False, "reason": "customer_account_exists"}
for admin_user in admin_users:
if admin_user.get("email", "").strip().lower() == email:
return {"safe": False, "reason": "admin_user_exists"}
for invite in pending_invites:
if invite.get("email", "").strip().lower() == email and invite.get("accepted") is not True:
return {"safe": False, "reason": "invite_pending"}
return {"safe": True, "reason": "ok"}
export function willInviteCollide(targetEmail, customers, adminUsers, pendingInvites) {
const email = targetEmail.trim().toLowerCase();
const customerHit = customers.find(
(c) => (c.email || "").trim().toLowerCase() === email && c.has_account === true
);
if (customerHit) return { safe: false, reason: "customer_account_exists" };
const adminHit = adminUsers.find((u) => (u.email || "").trim().toLowerCase() === email);
if (adminHit) return { safe: false, reason: "admin_user_exists" };
const inviteHit = pendingInvites.find(
(i) => (i.email || "").trim().toLowerCase() === email && i.accepted !== true
);
if (inviteHit) return { safe: false, reason: "invite_pending" };
return { safe: true, reason: "ok" };
}
Only create the invite when the check is clear
When willInviteCollide comes back safe, call POST /admin/invites with the email and role, the same way you would by hand. When it is not safe, never call that endpoint at all, report the reason, and point the operator at the documented workaround: invite a different email address. Never touch the existing customer or admin identity.
def create_invite(token, email, role):
headers = {"Authorization": f"Bearer {token}"}
r = requests.post(
f"{BASE_URL}/admin/invites",
json={"email": email, "role": role},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["invite"]
async function createInvite(token, email, role) {
const res = await fetch(`${BASE_URL}/admin/invites`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ email, role }),
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
return body.invite;
}
Wire it together with a dry run guard
The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only reports whether the target email would collide and why. Read the output, and if it says ok, switch DRY_RUN off to let it actually call POST /admin/invites. If it flags a collision, do not force it, invite a different email instead.
Never delete, edit, or otherwise force a customer's existing AuthIdentity to free up an email for an admin invite. That risks locking a real storefront customer out of their own account. Treat a collision purely as flag and block. The only supported repair is to invite a different email address for the admin user.
The full code
Here is the complete script in one file for each language. It authenticates, checks the target email against customers, admin users, and pending invites with a pure decision function, and either blocks the invite and reports the reason, or creates it when the check is clear, all gated by DRY_RUN.
"""Flag a Medusa v2 admin invite that will fail at accept time because the
target email already has a customer AuthIdentity. Both customer registration
and invite acceptance call the same auth provider register method, which is
keyed only on email with no actor_type awareness, so a customer identity on
that email guarantees a 401 Identity with email already exists when the
invite is accepted. This never mutates an existing identity. DRY_RUN=true
only reports the collision check. Safe to run again and again.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("check_invite_collision")
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")
TARGET_EMAIL = os.environ.get("TARGET_EMAIL", "jane@example.com")
INVITE_ROLE = os.environ.get("INVITE_ROLE", "admin")
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 find_customers(token, email):
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE_URL}/admin/customers",
params={"email": email, "fields": "id,email,has_account"},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["customers"]
def find_admin_users(token, email):
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE_URL}/admin/users",
params={"email": email, "fields": "id,email"},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["users"]
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"},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["invites"]
def will_invite_collide(target_email, customers, admin_users, pending_invites):
"""Pure: no I/O. Returns {"safe": bool, "reason": str}."""
email = target_email.strip().lower()
for customer in customers:
if customer.get("email", "").strip().lower() == email and customer.get("has_account") is True:
return {"safe": False, "reason": "customer_account_exists"}
for admin_user in admin_users:
if admin_user.get("email", "").strip().lower() == email:
return {"safe": False, "reason": "admin_user_exists"}
for invite in pending_invites:
if invite.get("email", "").strip().lower() == email and invite.get("accepted") is not True:
return {"safe": False, "reason": "invite_pending"}
return {"safe": True, "reason": "ok"}
def create_invite(token, email, role):
headers = {"Authorization": f"Bearer {token}"}
r = requests.post(
f"{BASE_URL}/admin/invites",
json={"email": email, "role": role},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["invite"]
def run():
token = get_token()
customers = find_customers(token, TARGET_EMAIL)
admin_users = find_admin_users(token, TARGET_EMAIL)
pending_invites = list_pending_invites(token)
decision = will_invite_collide(TARGET_EMAIL, customers, admin_users, pending_invites)
if not decision["safe"]:
log.warning(
"Blocked: invite to %s would collide (%s). Invite a different email instead.",
TARGET_EMAIL, decision["reason"],
)
return
log.info("Email %s is clear. %s", TARGET_EMAIL, "would create invite" if DRY_RUN else "creating invite")
if not DRY_RUN:
invite = create_invite(token, TARGET_EMAIL, INVITE_ROLE)
log.info("Invite created: %s", invite["id"])
if __name__ == "__main__":
run()
/**
* Flag a Medusa v2 admin invite that will fail at accept time because the
* target email already has a customer AuthIdentity. Both customer registration
* and invite acceptance call the same auth provider register method, which is
* keyed only on email with no actor_type awareness, so a customer identity on
* that email guarantees a 401 Identity with email already exists when the
* invite is accepted. This never mutates an existing identity. DRY_RUN=true
* only reports the collision check. 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 TARGET_EMAIL = process.env.TARGET_EMAIL || "jane@example.com";
const INVITE_ROLE = process.env.INVITE_ROLE || "admin";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function willInviteCollide(targetEmail, customers, adminUsers, pendingInvites) {
// Pure: no I/O. Returns { safe, reason }.
const email = targetEmail.trim().toLowerCase();
const customerHit = customers.find(
(c) => (c.email || "").trim().toLowerCase() === email && c.has_account === true
);
if (customerHit) return { safe: false, reason: "customer_account_exists" };
const adminHit = adminUsers.find((u) => (u.email || "").trim().toLowerCase() === email);
if (adminHit) return { safe: false, reason: "admin_user_exists" };
const inviteHit = pendingInvites.find(
(i) => (i.email || "").trim().toLowerCase() === email && i.accepted !== true
);
if (inviteHit) return { safe: false, reason: "invite_pending" };
return { safe: true, reason: "ok" };
}
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 apiGet(token, path, params) {
const url = new URL(`${BASE_URL}${path}`);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${path} ${res.status}`);
return res.json();
}
async function findCustomers(token, email) {
const body = await apiGet(token, "/admin/customers", { email, fields: "id,email,has_account" });
return body.customers;
}
async function findAdminUsers(token, email) {
const body = await apiGet(token, "/admin/users", { email, fields: "id,email" });
return body.users;
}
async function listPendingInvites(token) {
const body = await apiGet(token, "/admin/invites", { fields: "id,email,accepted,expires_at" });
return body.invites;
}
async function createInvite(token, email, role) {
const res = await fetch(`${BASE_URL}/admin/invites`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ email, role }),
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
return body.invite;
}
export async function run() {
const token = await getToken();
const [customers, adminUsers, pendingInvites] = await Promise.all([
findCustomers(token, TARGET_EMAIL),
findAdminUsers(token, TARGET_EMAIL),
listPendingInvites(token),
]);
const decision = willInviteCollide(TARGET_EMAIL, customers, adminUsers, pendingInvites);
if (!decision.safe) {
console.warn(
`Blocked: invite to ${TARGET_EMAIL} would collide (${decision.reason}). Invite a different email instead.`
);
return;
}
console.log(`Email ${TARGET_EMAIL} is clear. ${DRY_RUN ? "would create invite" : "creating invite"}`);
if (!DRY_RUN) {
const invite = await createInvite(token, TARGET_EMAIL, INVITE_ROLE);
console.log(`Invite created: ${invite.id}`);
}
}
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 the one that decides the outcome, will_invite_collide. It is pure, no network and no Medusa instance, just plain arrays in and a decision out, so the tests feed in fixture customers, admin users, and invites and check the answer.
from check_invite_collision import will_invite_collide
def test_ok_when_no_matches_anywhere():
result = will_invite_collide("new@example.com", [], [], [])
assert result == {"safe": True, "reason": "ok"}
def test_blocked_when_customer_has_account():
customers = [{"email": "jane@example.com", "has_account": True}]
result = will_invite_collide("jane@example.com", customers, [], [])
assert result == {"safe": False, "reason": "customer_account_exists"}
def test_not_blocked_when_customer_has_no_account():
customers = [{"email": "jane@example.com", "has_account": False}]
result = will_invite_collide("jane@example.com", customers, [], [])
assert result == {"safe": True, "reason": "ok"}
def test_blocked_when_admin_user_already_exists():
admin_users = [{"email": "jane@example.com"}]
result = will_invite_collide("jane@example.com", [], admin_users, [])
assert result == {"safe": False, "reason": "admin_user_exists"}
def test_blocked_when_invite_already_pending():
invites = [{"email": "jane@example.com", "accepted": False}]
result = will_invite_collide("jane@example.com", [], [], invites)
assert result == {"safe": False, "reason": "invite_pending"}
def test_not_blocked_when_invite_already_accepted():
invites = [{"email": "jane@example.com", "accepted": True}]
result = will_invite_collide("jane@example.com", [], [], invites)
assert result == {"safe": True, "reason": "ok"}
def test_normalizes_case_and_whitespace():
customers = [{"email": "Jane@Example.com", "has_account": True}]
result = will_invite_collide(" jane@example.com ", customers, [], [])
assert result == {"safe": False, "reason": "customer_account_exists"}
def test_customer_check_wins_over_other_reasons():
customers = [{"email": "jane@example.com", "has_account": True}]
admin_users = [{"email": "jane@example.com"}]
result = will_invite_collide("jane@example.com", customers, admin_users, [])
assert result["reason"] == "customer_account_exists"
import { test } from "node:test";
import assert from "node:assert/strict";
import { willInviteCollide } from "./check-invite-collision.js";
test("ok when no matches anywhere", () => {
const result = willInviteCollide("new@example.com", [], [], []);
assert.deepEqual(result, { safe: true, reason: "ok" });
});
test("blocked when customer has account", () => {
const customers = [{ email: "jane@example.com", has_account: true }];
const result = willInviteCollide("jane@example.com", customers, [], []);
assert.deepEqual(result, { safe: false, reason: "customer_account_exists" });
});
test("not blocked when customer has no account", () => {
const customers = [{ email: "jane@example.com", has_account: false }];
const result = willInviteCollide("jane@example.com", customers, [], []);
assert.deepEqual(result, { safe: true, reason: "ok" });
});
test("blocked when admin user already exists", () => {
const adminUsers = [{ email: "jane@example.com" }];
const result = willInviteCollide("jane@example.com", [], adminUsers, []);
assert.deepEqual(result, { safe: false, reason: "admin_user_exists" });
});
test("blocked when invite already pending", () => {
const invites = [{ email: "jane@example.com", accepted: false }];
const result = willInviteCollide("jane@example.com", [], [], invites);
assert.deepEqual(result, { safe: false, reason: "invite_pending" });
});
test("not blocked when invite already accepted", () => {
const invites = [{ email: "jane@example.com", accepted: true }];
const result = willInviteCollide("jane@example.com", [], [], invites);
assert.deepEqual(result, { safe: true, reason: "ok" });
});
test("normalizes case and whitespace", () => {
const customers = [{ email: "Jane@Example.com", has_account: true }];
const result = willInviteCollide(" jane@example.com ", customers, [], []);
assert.deepEqual(result, { safe: false, reason: "customer_account_exists" });
});
test("customer check wins over other reasons", () => {
const customers = [{ email: "jane@example.com", has_account: true }];
const adminUsers = [{ email: "jane@example.com" }];
const result = willInviteCollide("jane@example.com", customers, adminUsers, []);
assert.equal(result.reason, "customer_account_exists");
});
Case studies
The support lead who was already a customer
A store promoted its most active customer support volunteer to an admin role. The invite went out through POST /admin/invites, HR confirmed the email in the inbox, and everyone assumed it was done. The volunteer clicked accept, set a password, and got a 401 that made no sense to anyone on the team, because nothing in the invite flow had ever complained.
Running the collision check against that email showed customer_account_exists, tied to the account they had used for years to place orders. The team invited their work email instead, which was clear on every check, and the admin role landed in minutes instead of another afternoon of confused back and forth with support.
The agency onboarding a batch of client admins
An agency running Medusa storefronts for clients invited a batch of ten client contacts as admins in one sitting. Two of those emails had, months earlier, been used to place a test order on the storefront during a demo, quietly creating customer identities nobody remembered.
Running the check across the whole batch before sending anything flagged those two immediately with customer_account_exists, while the other eight came back ok. The agency invited alias addresses for those two contacts instead of guessing why two invites out of ten would fail later, and no invite went out that was destined to fail.
Run this check before every admin invite, not after a confused support ticket. It never touches an existing AuthIdentity and never guesses at a merge, it only tells you, using the same proxy signal Medusa itself would eventually fail on, whether the email is safe to invite. When it says ok, send the invite exactly as you always have. When it flags a collision, the fix is always the same and always safe: use a different email address for the admin account, and leave the existing customer alone.
FAQ
Why does accepting a Medusa admin invite fail with Identity with email already exists?
Both customer registration and admin invite acceptance call the same auth provider register method, which looks up a single AuthIdentity row by email. If that email already registered as a customer, the AuthIdentity already exists with app_metadata pointing to a customer actor, and the register flow has no actor_type awareness, so it cannot tell an admin identity apart from the existing customer one. It returns a 401 instead of creating a separate admin identity, so the invite was sent but can never be accepted.
How do I detect that an admin invite will fail before I send it?
Medusa's admin API has no direct route to list auth identities by actor_type, so check the reliable proxy signal instead. Call GET /admin/customers with the target email and fields=id,email,has_account. If has_account is true, that email already owns a customer AuthIdentity and any admin invite to it will fail at accept time. Also check GET /admin/users for an existing admin, and GET /admin/invites for a pending unaccepted invite to the same email.
Can I fix the collision by deleting the customer's auth identity to free up the email?
No, that is not a safe API-level fix. Medusa does not support one email owning both a customer and an admin AuthIdentity in the affected versions, and forcing a delete or edit of the customer's identity risks locking a real storefront customer out of their account. The documented workaround is to invite a different email address for the admin user, never to mutate the existing customer identity.
Related field notes
Citations
On the problem:
- medusajs/medusa GitHub issue #12521: Admin user invitation fails if email is already used by a customer (Identity with email already exists). github.com/medusajs/medusa/issues/12521
- medusajs/medusa GitHub issue #10607: Can not register a customer which already has an admin account, and vice versa, with the same authentication provider. github.com/medusajs/medusa/issues/10607
- Medusa Documentation: Customer Accounts. docs.medusajs.com/resources/commerce-modules/customer/customer-accounts
On the solution:
- Medusa Documentation: Authentication Flows with the Auth Module. docs.medusajs.com/resources/commerce-modules/auth/auth-flows
- Medusa Documentation: Manage Invites in Medusa Admin. docs.medusajs.com/modules/users/admin/manage-invites
- Medusa Documentation: Admin API Reference. docs.medusajs.com/api/admin
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.
Did this save you a dead-on-arrival invite?
If this saved you from a confusing support ticket or a wasted afternoon chasing a 401 that made no sense, 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