Open DevTools on almost any Shopify store running a themed bundle builder and look at the cart payload. Somewhere in the line-item properties you'll find the theme talking to itself: _bundlePrice: 2499, _starterSet: 1724631882, _bundleSize: 3. Hints the storefront leaves so that something downstream (a script, an app, a discount) knows what the shopper built.
Here's the uncomfortable truth: every one of those values is attacker-controllable. Line-item properties and cart attributes are written by client-side code, which means they're written by whoever's holding the client. A shopper doesn't need a proxy or any real skill. fetch('/cart/add.js', ...) with a doctored _bundlePrice is a paste-into-console operation. If your discount logic trusts that number, you've shipped a pricing API where the customer names their own discount.
We've reviewed setups where exactly that was live. Not because anyone was careless, really, because the theme and the discount grew up together, and the property started life as an internal note between two pieces of code the same developer controlled. Then the discount side started believing it.
The rule
Our discount functions follow one rule, everywhere, no exceptions:
Client-written data may select an offer. Only merchant-owned data may price it.
Concretely, in a Shopify Function:
_bundlePrice(client) says what the shopper is asking for. The function recovers the requested discount from it, and then validates the amount against a trusted cap stored in a variant metafield that only the merchant's admin (or our portal, with an authenticated Admin API token) can write. Request ≤ cap: the discount applies. Request > cap: the line is refused. A tampered cart can, at absolute worst, award itself the discount the merchant already approved._starterSet+ the bundle's expected line count (client) say these items came in together. The function checks the whole set is still in the cart before any line qualifies. Remove one item and the rest stop pretending. Integrity, not pricing.- Matched quantities are computed as the minimum across the bundle's lines, so inflating one line's quantity can't over-claim. Same trick as in the subscription-aware bundle.
- The trusted side (caps, pairings, campaign state) lives in metafields with reserved namespaces (
$app:), which storefront code cannot write at all. That's the property that makes the whole design hold: the validation data has a different write-permission class than the claim data.
And because the logic runs as a Shopify Function, compiled WebAssembly executing inside Shopify's checkout, not a script tag a shopper can read or block, the validation isn't advisory. It's the only path to the discount. What Shopify Functions can actually do covers the APIs; this is the security posture we put on every one of them.
This is also why a Checkout Suite build is not "the same widget, cheaper." The function is written against your metafields, your caps, your campaigns. A rented upsell app cannot make that write-permission split, because it does not own your admin.
Test the binary, not the source
One more habit worth stealing: our fixture suites run against the compiled function.wasm, through the same runner Shopify uses, not just the JavaScript source. Unit tests prove the logic; the wasm fixtures prove the artifact, including the tampered-cart cases (03-tampered-price-blocked.json is our favorite filename in the repo). When the input query, the toolchain, or an API version changes, the thing that gets re-verified is the thing that actually runs at checkout.
None of this is exotic. It's the same lesson web security learned twenty years ago (never trust the client) applied to a place where the client is unusually well-dressed: your own theme, talking about money.
Caffeine and Commerce builds and audits custom discount functions for Shopify stores. If your bundle pricing trusts a line-item property, we'd genuinely love to look at it, before someone else does. Start a project.


Comments
Every comment here comes from a verified email. Write yours, confirm from your inbox, and it's live.
Loading comments…