Medusa 2.20.0 patches payment provider and admin MFA gaps
Medusa 2.20.0 fixes payment providers reachable outside their region and an admin MFA bypass — what changes, how to check your config, and what to test.
- Medusa.js
- Migrations
- Operations

You set up three regions. One of them uses a payment provider that only settles in a single currency, and you assumed the checkout for your other regions could not reach it. Until Medusa 2.20.0, it could. The payment provider ID sent when creating a payment session was trusted as submitted, so any provider installed anywhere in the application was reachable from any cart, regardless of which region that cart belonged to.
The same release closes a second gap: an attacker who already held a user's valid password could use that partially-authenticated token to regenerate admin recovery codes and complete the MFA challenge with one of them, or enroll and delete MFA factors outright. The second factor was not actually gating the routes that manage the second factor.
Both fixes ship in v2.20.0. The release notes list them under "Highlights" rather than as a security advisory, with no CVE or severity attached, which is part of why they are easy to scroll past. Note that v2.20.1 has since shipped and is the current release in the repository — upgrade to that rather than pinning to 2.20.0, though everything described below is what landed in 2.20.0.
The payment provider fix restores a region boundary you probably assumed existed
The change is in createPaymentSessionsWorkflow and the cart payment validation step. Both now verify that the chosen provider is actually enabled in the cart's — or the payment collection's — region, and reject the request if it is not. Previously the provider ID from the request body went through unchecked.
What this means in practice depends on how many providers you have installed. If you run a single provider across every region, the exposure was nil and the fix is a no-op. If you run different providers per region — a regional acquirer here, a wallet or BNPL provider there, a manual/offline provider for wholesale — then any of those was addressable from any cart before this release. The manual provider is the one worth thinking about hardest: a provider that marks a payment as authorized without moving money is exactly the thing you do not want reachable from a public storefront cart.
The upgrade note says no action is required, and for well-formed storefronts that is true. It is only false if your storefront deliberately passed a provider ID that was never linked to the region — that request now fails with a rejection instead of silently working.
How to check whether your own configuration was exposed
Three checks, in order of how fast they are.
First, enumerate what is installed versus what is linked. Open your payment module configuration in medusa-config.ts and list every provider registered there. Then walk your regions in the admin and note the providers linked to each. If the installed set is larger than the union of the linked sets — or if any single region's linked set is a strict subset of what is installed — you had reachable providers outside their intended region.
Second, look for evidence in your own data. Query payment sessions and payment collections joined back to the order or cart's region, and flag any row whose provider is not among the providers linked to that region. On a healthy store this returns nothing. Anything it does return deserves a look before you assume it was a test order.
Third, if you have a manual or offline provider installed for internal use, confirm it is linked only to regions where that is intended, and consider whether it needs to exist in the production application at all.
The relations limit will break storefront queries before the security fixes ever bite
This is the change most likely to take down your storefront build, and it is flagged as breaking. Store API routes now reject requests that expand more than three levels of relations through fields. A query like fields=*products.variants.options.values now returns a 400 listing the offending fields. Admin routes are unaffected. The product routes — GET /store/products and GET /store/products/:id — are the only core Store routes that raise the limit, to four levels.
You can raise it globally with storeRelationsLimit under projectConfig.http in medusa-config.ts, or per-route by passing storeRelationsLimit in the query config given to validateAndTransformQuery in your middlewares, which takes precedence over the app-wide value. Our advice is to resist the global bump as a first move. Grep your storefront for fields= and count the dots; the deep expansions you find are usually a single overfetching call that should have been two requests or a purpose-built route. Raise the limit for that one matcher if you need to ship today, then fix the query.
Run db:migrate after upgrading. It is required for this release, not optional housekeeping.
Draft orders now calculate shipping, which changes manual order totals
Draft orders support shipping options with calculated prices, so third-party rate providers can be used when an operator builds an order by hand. A new setCalculatedShippingPricingContext hook lets you inject additional data into the pricing context sent to the fulfillment provider during that calculation.
This is a genuine improvement for phone and B2B orders, and it also means a manually built order can now come back with a shipping total your operators did not type in. If your finance process assumes draft order totals are fully operator-controlled, test that assumption before this reaches production: build a draft order against a calculated shipping option, confirm the rate that comes back matches what the same cart would get through checkout, and confirm what happens when the rate provider is slow or errors.
Two more upgrade-day surprises worth staging
If you use @medusajs/caching-redis, the provider now stores entries under a 64-bit hash key with a simplified storage mechanism. Entries written by earlier versions are unreadable in the new format and are effectively invalidated on deploy. No config change is needed, but plan for a cold cache and the traffic spike that implies on your first minutes after cutover.
If you were using @medusajs/search-local, the Orama-backed in-memory provider from v2.19, it is gone. You need @medusajs/search-postgres, the Medusa Cloud provider, or a custom one — and search indexes are no longer created at application startup, so db:migrate creates them. Custom search providers must now implement searchMany, which moved down to the provider interface.
What to do next
Stage the upgrade to the current 2.20.x release this week rather than batching it into your next quarterly bump. Before you deploy: run the three provider checks above, grep your storefront for relation expansions deeper than three levels, and confirm your admin users' MFA enrollment still works end to end after the challenge-enforcement change. After you deploy: verify a real checkout in each region, watch for 400s on store routes, and expect cold-cache latency if you run Redis caching.
If you are still evaluating Medusa rather than running it, this release is a reasonable data point in both directions — the region-scoping bug existed, and it was found and fixed with a clear explanation of the behavior change. That is what we look for when we plan a migration off Magento or another legacy platform: not the absence of security bugs, but a maintainer that describes them plainly and ships the fix with the upgrade notes attached. If you want a second pair of eyes on your region and payment provider configuration before or after the upgrade, get in touch.
Need this done on a real stack?
Magento 2, Adobe Commerce, migrations to Medusa.js or Vendure, enterprise Next.js, WordPress, and AI automation.
Contact us