Medusa 2.21.2 cart bug: line items deletable across carts
On Medusa 2.21.2, the store line-item DELETE route can remove items from another shopper's cart. How it happens, who is exposed, and an interim guard.
- Medusa.js
- Migrations
- Operations

A shopper adds two items, comes back an hour later, and one is gone. There is nothing in your logs from their browser, no storefront error and no failed request. On Medusa 2.21.2 one possible explanation has nothing to do with their session: somebody else removed the item by sending a request against their own cart.
The route trusts the line ID and ignores the cart
The bug is reported in medusajs/medusa issue #17091 against a stock 2.21.2 install. The handler for DELETE /store/carts/:id/line-items/:line_id passes cart_id from the path and ids: [line_id] into deleteLineItemsWorkflow. The workflow locks and refreshes the cart named in the URL. The actual delete, though, is deleteLineItemsStep(input.ids), and it has no cart_id filter. Any line item ID you send gets deleted, whichever cart it belongs to.
The response makes the problem harder to spot. The caller gets a 200 with their own cart unchanged, and the other cart loses the item without any visible sign. The POST on the same path, which updates quantity, handles this correctly: updateLineItemInCartWorkflow returns 404 when the item is not in the cart. Two verbs on the same URL check ownership differently, and that kind of inconsistency gets past code review because the path looks like it already scopes the item to the cart.
This is broken object-level authorization in its plainest form. The URL implies a parent-child relationship that the server never checks.
Who is exposed, and how badly
Any storefront running the default Store API cart routes on 2.21.2 is exposed. Creating a cart only needs the publishable key, and that key ships to every browser by design. So you should assume an attacker can always get a cart of their own.
The limiting factor is the line item ID. These IDs are ULIDs, so guessing them is not realistic, and an attacker has to get the ID from somewhere. The issue names logs, screenshots and support tickets. In practice, also count anything that captures request URLs or cart payloads: client-side error trackers, session replay tools, analytics events that serialize the cart, and a customer pasting cart JSON into a support chat.
The bug does not expose any data. The harm is to integrity: a shopper's cart loses items without them noticing. You might see it as an unexplained drop-off or a support ticket nobody can reproduce. We would not wake anyone up for this, but it is cheap to close, so we would not leave it open either.
An interim guard in middleware
The reporter's workaround has the right shape. Add a middleware on /store/carts/:id/line-items/:line_id, for both POST and DELETE, that loads the line item through the Cart Module. It returns 404 unless the item's cart_id matches req.params.id. POST is already correct today, so guarding it is redundant, but it costs nothing and protects you if that behavior regresses.
// src/api/middlewares.ts
import {
defineMiddlewares,
MedusaNextFunction,
MedusaRequest,
MedusaResponse,
} from '@medusajs/framework/http'
import { MedusaError, Modules } from '@medusajs/framework/utils'
async function assertLineItemInCart(
req: MedusaRequest,
res: MedusaResponse,
next: MedusaNextFunction
) {
const { id: cartId, line_id: lineId } = req.params
const cartModule = req.scope.resolve(Modules.CART)
const [line] = await cartModule.listLineItems(
{ id: lineId, cart_id: cartId },
{ select: ['id', 'cart_id'], take: 1 }
)
if (!line) {
return next(
new MedusaError(MedusaError.Types.NOT_FOUND, 'Line item not found')
)
}
next()
}
export default defineMiddlewares({
routes: [
{
matcher: '/store/carts/:id/line-items/:line_id',
methods: ['POST', 'DELETE'],
middlewares: [assertLineItemInCart],
},
],
})
A few notes before you ship it:
- If you already have a
middlewares.ts, merge this route into your existingdefineMiddlewarescall. Do not add a second default export. - Check the
listLineItemssignature against the@medusajs/frameworktypes you actually have installed. Module service method names are generated, and they are the part of this snippet most likely to drift. - Return 404, not 403. That matches the POST route, and it avoids telling a caller that the ID exists somewhere else.
- Test it with the reproduction from the issue. Create cart A and cart B with the publishable key and add one item to each. Then call
DELETE /store/carts/{cart_A}/line-items/{li_B}. You should get a 404, and cart B should still containli_B.
The proper fix belongs in the workflow
The issue also proposes the real fix. Before deleteLineItemsStep runs, load the items with filters: { id: input.ids, cart_id: input.cart_id } and throw NOT_FOUND unless every requested ID is in that cart. A fix in the workflow beats a check in the route because it covers every caller that passes a cart ID. The middleware only protects one HTTP path. If your own route, subscriber or job calls deleteLineItemsWorkflow with a line ID that a user can influence, the middleware does nothing for that call.
The issue does not name a release that contains a fix, so do not assume one exists. Watch the issue. When a release claims to fix it, read the changelog or diff and confirm the check landed in the workflow, then upgrade. You can keep the middleware afterward; it costs one indexed query per request.
Audit custom store routes for the same pattern
This bug is one instance of a pattern, and custom store routes are where the pattern spreads. That is especially true after a replatform, when cart, wishlist or address features were rebuilt quickly as Medusa routes. In Magento, quote ownership is handled by masked quote IDs and customer tokens, so nobody on the project had to think about it. After moving off Magento 2, you have to rebuild that ownership logic yourself, route by route.
A quick audit:
- List every nested route with two dynamic segments:
find src/api/store -path '*\[*\]*\[*\]*' -name route.ts. - In each handler, follow the child ID to the query or workflow that uses it, and confirm that the parent ID is part of the filter. A check that only loads the parent does not count.
- Grep for workflows and steps that receive bare
idsarrays (grep -rn 'ids:' src/workflows src/api/store) and check where those IDs come from. - For routes scoped to a customer, confirm that ownership comes from
req.auth_context, not from anything in the path or body. - Write the negative test for each one: two parents, request a child through the wrong parent, expect a 404, and confirm the other parent is unchanged.
That last test is the one that would have caught this bug. Its setup is short enough to template once and reuse for every nested route.
What to do next
If you run 2.21.2, add the middleware today and run the cross-cart reproduction against staging to prove it works. Then search your error tracker and replay tooling for stored cart payloads and line item IDs, and cut down what they keep. Then run the five-step audit on your custom store routes. A fix you write yourself is easier to trust than one in a release you have not read.
If you are partway through a migration to Medusa or Vendure and want someone to review the authorization of your custom store API before launch, 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