Skip to main content
Commerce

Medusa v2 Admin API Returns 401: The Five Real Causes (2026)

POST /auth/user/emailpass returns 200, GET /admin/users/me returns 401. The five real causes: actor_type wrong, secure-cookie blocked on localhost, NGINX stripping cookies, CORS misconfig, and publishable keys on admin routes.

MManojAugust 9, 202612 min read
More in Commerce#medusa#troubleshooting
Share

A Medusa v2 admin API 401 with a login that just returned 200 is almost always one of five things: the auth call went to the wrong actor_type (customer vs user), the session cookie is being blocked by secure: true on a localhost HTTP dev server, a reverse proxy is dropping the cookie in transit, CORS is misconfigured for a cross-origin admin app, or you are sending a publishable API key on /admin/* where only JWTs and admin session cookies work. Each has a specific signature — none requires you to touch your business logic.

Every migrating-from-v1 ticket we take on Medusa starts the same way: the login route succeeds, the next admin call fails with a Medusa v2 admin api 401, and the developer assumes their password is wrong. It almost never is. I run Medusa v2 implementation programmes at MithTech, a Bangalore-based engineering practice that designs, deploys and operates commerce infrastructure for retailers and B2B operators across India, and this is the diagnostic order we walk on Medusa v2 auth tickets.

The five causes at a glance

Skimmable summary: cheapest check first — the actor_type mistake and the localhost cookie problem cover the majority of tickets.

SignatureFirst check
1Wrong actor_type in auth URLLogin to /auth/customer/emailpass succeeds, /admin/* returns 401Confirm the login URL contains /auth/user/, not /auth/customer/
2Secure-cookie blocked on localhostBrowser DevTools → Application → Cookies: connect.sid absent after loginCheck secure flag on the cookie header from Medusa's response
3Reverse proxy dropping Set-Cookieconnect.sid present in the response headers, absent in the requestSet trustProxy: true + confirm X-Forwarded-Proto: https
4Cross-origin fetch without credentialsConsole shows a CORS warning, no cookies attached to the admin requestCheck credentials: 'include' on fetch + server CORS
5Publishable key on /admin/*401 on admin call, publishable key correctly configured for /storeConfirm the request is using JWT or session — not x-publishable-api-key

Are you authenticating against the right actor_type?

Answer

Medusa v2 separates admin users and customers into two actor_type values with two entirely different auth URL prefixes. POST /auth/user/emailpass returns a JWT valid for /admin/* routes; POST /auth/customer/emailpass returns a JWT valid for /store/customer/* routes. A JWT for one is a 401 for the other. Confirming the URL contains user (not customer) closes a surprising fraction of these tickets.

Skimmable summary: one letter in the URL decides which half of the API you can hit.

The v2 auth pattern:

# Admin (merchant) login → JWT valid for /admin/*
curl -X POST https://api.example.com/auth/user/emailpass \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@example.com","password":"..."}'

# Storefront (shopper) login → JWT valid for /store/customers/me
curl -X POST https://api.example.com/auth/customer/emailpass \
  -H "Content-Type: application/json" \
  -d '{"email":"shopper@example.com","password":"..."}'

Using the JS SDK, the actor type is the first argument to login:

// Admin
await sdk.auth.login("user", "emailpass", { email, password })

// Storefront
await sdk.auth.login("customer", "emailpass", { email, password })

If you copy-pasted from a storefront tutorial into your admin app, the actor type may be wrong. The JS SDK auth overview documents the full signature.

Skimmable summary: on medusa start, cookies are secure: true by default — a browser on localhost HTTP will refuse them.

The clearest symptom: POST /auth/user/emailpass/session returns 200 OK, DevTools → Network → Response Headers shows Set-Cookie: connect.sid=...; Secure; HttpOnly, and DevTools → Application → Cookies shows no connect.sid for your localhost domain.

Cause: when you run medusa start (production mode) locally, Medusa sets secure: true on session cookies. Browsers reject secure cookies over plain HTTP. Your login succeeds server-side, no cookie is stored client-side, and the next admin request has nothing to authenticate with.

Two fixes:

A. Local dev — drop secure explicitly. In medusa-config.ts:

export default defineConfig({
  projectConfig: {
    http: {
      cookieOptions: {
        secure: false,
        sameSite: "lax"
      },
    },
    // ... rest of config
  },
})

Restart the server. connect.sid will now be settable on localhost.

B. Use medusa develop for local work. In develop mode Medusa runs with secure: false automatically. The trap is developers running medusa start locally "to test production behaviour" and then debugging a phantom auth issue.

For a real production HTTPS check locally, terminate TLS with an Ngrok or Cloudflare tunnel so the browser sees https://<subdomain>.ngrok-free.app — then the secure cookie works and you're actually testing what deploy behaves like.

Skimmable summary: NGINX Ingress + Kubernetes is the classic setup where login works via port-forward and fails via the public URL.

Issue #14550 reports exactly this on Medusa v2.11.0: login via NGINX Ingress returns 200, session endpoint returns 200, connect.sid cookie is set, and the very next /admin/users/me returns 401. Direct pod access via kubectl port-forward works.

Root cause on reverse-proxy setups: Medusa needs to know it is behind HTTPS to set the secure cookie correctly, and it needs to trust the X-Forwarded-Proto: https header the proxy is adding. If either half is missing, the cookie handling gets confused.

Fix on the Medusa side, in medusa-config.ts:

export default defineConfig({
  projectConfig: {
    http: {
      trustProxy: true,
    },
  },
})

Fix on the NGINX side — confirm the ingress annotations forward the right headers:

nginx.ingress.kubernetes.io/proxy-set-headers: |
  X-Forwarded-Proto: https
  X-Forwarded-Host: $host
  X-Forwarded-For: $proxy_add_x_forwarded_for
  X-Real-IP: $remote_addr

On Coolify or a Docker-Compose Traefik setup, the equivalent Traefik middleware is headers.customRequestHeaders.X-Forwarded-Proto=https. On a bare NGINX in front of a Docker container, proxy_set_header X-Forwarded-Proto https; inside the location block. See the Coolify + Hetzner Medusa deploy guide for the full config.

Skimmable summary: cross-origin admin apps need explicit CORS + credentials on both sides — the default fetch does not send cookies to a different origin.

If your admin app runs on admin.example.com and the API on api.example.com, this is a cross-origin request. Cookies do not travel by default across origins even to sibling subdomains. Two things must be true:

Client-side fetch — always include credentials:

fetch("https://api.example.com/admin/users/me", {
  credentials: "include",  // ← without this, cookies are not sent cross-origin
  headers: { "Content-Type": "application/json" },
})

If you're using the JS SDK, set auth.type: "session" and fetchCredentials: "include":

const sdk = new Medusa({
  baseUrl: "https://api.example.com",
  auth: {
    type: "session",
    fetchCredentials: "include",
  },
})

Server-side CORS — allow the origin and credentials:

In medusa-config.ts, list the exact admin origins under admin.cors. The wildcard * is invalid when credentials: true — browsers reject it. Set the specific origins:

projectConfig: {
  http: {
    adminCors: "https://admin.example.com,http://localhost:5173",
    storeCors: "https://shop.example.com",
    authCors: "https://admin.example.com,https://shop.example.com",
  },
},

The authCors origin list must include every origin that will hit /auth/* — a common oversight when the admin app moved to a new domain and only adminCors was updated. For a Razorpay-driven storefront hitting /store/* from a separate origin, the same principle applies to storeCors — see the Medusa + Razorpay India setup guide.

Are you sending a publishable key on an /admin/* route?

Skimmable summary: publishable keys are for /store/* only — they do not authenticate an admin request.

Publishable API keys in Medusa v2 exist to scope /store/* requests to specific sales channels. They are not admin credentials. If your migrating-from-v1 code does:

// This works on /store/products (v2 requires it there):
fetch("/store/products", {
  headers: { "x-publishable-api-key": PK },
})

// This does NOT work — /admin routes need JWT or session cookie:
fetch("/admin/products", {
  headers: { "x-publishable-api-key": PK },  // ← ignored; response is 401
})

The fix is unglamorous: use the right credential for the route family. For /admin/*, either send Authorization: Bearer <jwt> (obtained from /auth/user/emailpass) or ensure the session cookie is being sent. For /store/* requests, keep sending x-publishable-api-key.

What is the correct end-to-end auth flow?

Choose your auth model up front — JWT or session

JWT is stateless and works well from mobile apps, Jamstack storefronts, or server-to-server code — the client stores the token and sends it as Authorization: Bearer. Session is stateful and works well from a browser-based admin app — the server sets a cookie the browser sends automatically. Do not mix the two on the same client.

For JWT flow — call POST /auth/user/emailpass, keep the JWT

POST https://api.example.com/auth/user/emailpass with { email, password } in the JSON body returns { token: "<jwt>" }. Store the JWT in memory, localStorage, or a secure cookie your app manages — Medusa does not manage it for you in JWT mode. Send it as Authorization: Bearer <jwt> on every subsequent /admin/* request.

For session flow — call both /auth/user/emailpass AND /session

Session mode requires a second call. First POST /auth/user/emailpass returns a JWT. Then POST /auth/user/emailpass/session with that JWT in the Authorization header converts it into a connect.sid cookie the server sets on the response. From that point, cookies handle everything — you do not manually send the JWT again.

Verify the cookie survived the round trip

In DevTools → Application → Cookies for the API domain, look for connect.sid. If it is missing after a 200 on the session endpoint, walk causes 2, 3 and 4 in order — localhost secure-flag, reverse-proxy header handling, and CORS.

Test with GET /admin/users/me

This is the canonical smoke test. A 200 with your user object confirms the whole chain (login + session/JWT + cookie/header transport + CORS). A 401 here means the credential is not reaching the server; a 403 means it reached the server but lacks permission for that resource.

FAQ

The four questions Medusa v2 auth tickets generate most often.

Why does my admin login return 200 but the very next admin call returns 401?

Because login returned 200 does not mean a session was established. In JWT mode, login returns a token you must then attach to every subsequent request as Authorization: Bearer <jwt>. In session mode, login is a two-step chain — /auth/user/emailpass gives you a JWT, and you have to make a second call to /auth/user/emailpass/session to convert that into a cookie. If you stopped after the first call, no admin credential was ever stored.

Should I use JWT or session auth for a Medusa v2 admin?

Session if the admin runs in a browser on a stable origin — the cookie handling is invisible once configured. JWT if the admin is a mobile app, a Jamstack SPA on a different origin, or any headless integration where you want the client to explicitly manage the token. The JS SDK auth overview covers configuration for both.

Do I need a publishable API key for admin routes on v2?

No. Publishable API keys are only for /store/* routes, where they scope the request to specific sales channels. /admin/* routes are authenticated with a JWT or session cookie belonging to a user (admin merchant). Sending an x-publishable-api-key header on an admin request is silently ignored by the auth middleware.

Can the v2 admin API 401 on a valid JWT if the JWT has expired?

Yes. JWTs are signed with a lifetime, and after expiry the same request that succeeded an hour ago returns 401. If your admin session works after login and then starts returning 401 well into a long session, the JWT expired — re-authenticate. Session cookies have their own expiry sourced from session.ttl in medusa-config.ts; the same rule applies.

Medusa v2 admin API returning 401 on a fresh login?

MithTech designs, deploys and operates Medusa v2 commerce systems for Indian retailers and B2B operators — from local dev setup on Coolify to production behind NGINX Ingress on Kubernetes. If your admin auth is broken and the ladder above did not resolve it, we can help you find where in the chain the credential is getting lost.

Next step · Commerce

Now see what a store you own would look like

How we build and run Medusa storefronts and B2B portals connected to ERPNext, and when staying on a hosted platform is the better call.

M

Written by

Manoj

Founder of MithTech, an open-source ERP & automation engineering practice. Hands-on ERPNext/Frappe implementation across multi-branch, multi-warehouse Indian operations — GST/TDS/PT compliance, branch-level permissions, and custom Frappe apps that give management real-time visibility.

Free · By email

Get practical ERPNext & automation guides

New implementation guides, cost breakdowns and open-source tips for Indian businesses — occasionally, straight to your inbox. No spam.

Already a MithTech client?

Help the next operator choose.

Most teams evaluating ERPNext have no way to tell who actually delivers. If we’ve run an implementation for you, two lines on Google count for more than anything we can write about ourselves.

Leave a Google review

Only if we’ve actually worked together — Google filters reviews from non-customers, so an honest one is worth more than ten polite ones.

Keep reading

See what this looks like for your business

A 30-minute working session with a principal consultant. We pressure-test the architecture and outline the engagement model that fits your governance and procurement posture. You leave with a written brief.

0
Published on 9 August 2026

Manoj

Comments & ratings

No comments yet. Start a new discussion.