So I found a bug in WooCommerce that lets you use a coupon unlimited times, even when the store owner set a strict usage limit of 1.
And yes, I got paid $100 for it.
It’s not a huge bounty, but the bug itself? Pretty fun. Let me break it down.
The setup
Picture this: you’re a store owner. You create a coupon LIMIT1 — fixed cart discount of $10, global usage limit of 1. Only one customer in the entire store should ever be able to use this coupon.
That’s a pretty standard financial control, right? Flash sale, limited-time offer, whatever.
Now here’s the thing. WooCommerce has this Store API feature that lets customers create “checkout-draft” orders. You add items to your cart, the system creates a draft order with a coupon applied, and you pay later.
Normal flow? Perfectly fine.
But what if you could create multiple draft orders with that same coupon before anyone else uses it?
The idea
I was poking around the Store API endpoints when I noticed something interesting.
The coupon validation happens at different stages depending on how you’re checking out:
- Fresh checkout (normal cart flow): WooCommerce validates coupon constraints before payment, including the global usage limit
- Existing order checkout (paying for a previously created draft): The validation path is different
That distinction is where things get spicy.
The exploit
Here’s the attack chain:
Step 1: Pre-stage draft orders while the coupon is still valid
As a guest user (no WordPress account needed), create three separate checkout-draft orders with LIMIT1 applied:
POST /wp-json/wc/store/v1/checkout?billing_email=attacker@example.testContent-Type: application/json
{ "coupon_code": ["LIMIT1"], "billing_address": { ... }, "payment_method": "bacs"}Each order gets created successfully because the coupon hasn’t been exhausted yet.
Save the order_id, order_key, and billing_email for each.
Step 2: Someone else uses the coupon
A legitimate customer comes along and completes a normal checkout with LIMIT1.
The coupon’s usage_count is now 1. The global limit is reached.
A fresh checkout attempt with LIMIT1 is correctly rejected:
{ "code": "woocommerce_rest_cart_coupon_error", "message": "Usage limit for coupon \"limit1\" has been reached."}WooCommerce is doing its job. The coupon is exhausted.
Step 3: Pay the old draft orders
Now here’s where the bug lives.
Remember those pre-staged draft orders from Step 1? They already have the coupon applied.
The existing-order checkout endpoint lets you pay for them:
POST /wp-json/wc/store/v1/checkout/19?key=wc_order_tIFFUEeq38Bnv&billing_email=attacker@example.testContent-Type: application/json
{ "billing_address": { ... }, "shipping_address": { ... }, "payment_method": "bacs", "payment_data": []}Response:
{ "order_id": 19, "status": "on-hold", "payment_result": { "payment_status": "success" }}It works. The order goes through with the $10 discount.
Do this for all three pre-staged orders. All of them succeed.
Final state
{ "code": "limit1", "usage_count": 4, "usage_limit": 1, "used_by": [ "b@example.test", "attacker@example.test", "attacker@example.test", "attacker@example.test" ]}Usage limit: 1. Actual usage: 4.
The attacker got $30 in unauthorized discounts. Scale this up with more pre-staged orders and higher-value coupons, and the financial impact grows.
Why it happened
The root cause is a validation gap between two checkout paths.
The normal checkout flow calls WC_Coupon_Data_Store_CPT::check_and_hold_coupon(), which handles global usage-limit enforcement before payment.
The existing-order payment path in CheckoutOrder::get_route_post_response() calls validate_existing_order_before_payment(), which runs:
$this->validate_coupons( $order, true );But validate_coupons() only runs:
array( 'validate_coupon_email_restriction', 'validate_coupon_usage_limit' )And validate_coupon_usage_limit() only checks the per-user limit:
$coupon_usage_limit = $coupon->get_usage_limit_per_user();
if ( 0 === $coupon_usage_limit ) { return; // no per-user limit set, skip validation}For a coupon with only a global usage_limit, this validator returns without checking anything.
The global usage-limit check that happens in the normal checkout path? It’s completely skipped in the existing-order payment path.
Two code paths, two different levels of validation. That’s the bug.
The report
I submitted this to Automattic on HackerOne.
The triage was a bit of a rollercoaster:
- May 23: Submitted the report
- June 1: Triaged as “Needs more info” and downgraded from High to Low severity
- June 1: Provided screencast + raw HTTP evidence files
- June 30: Asked for an update
- ~August 13: Resolved with a fix and $100 bounty
The severity downgrade was fair. It’s a local business logic bug, not remote RCE. But the $100 bounty for a clean logic bypass? I’ll take it.
Lessons learned
1. Check alternate code paths. The main checkout flow was solid. But the existing-order payment path had a completely different validation chain. Always ask: “is there another way to reach the same endpoint?”
2. Business logic bugs are still bugs. No fancy exploitation, no buffer overflows, no SQL injection. Just a coupon being used more times than it should be. Store owners care about their margins.
3. Guest checkout = wider attack surface. No account required. No authentication needed. Just create draft orders as a guest and pay later. That’s a pretty low bar for exploitation.
4. Small bounties add up. $100 isn’t life-changing, but it’s $100 I didn’t have before for ~2 hours of work. That’s a solid hourly rate.
Affected version
- WooCommerce 10.7.0
- Other versions may be affected (not tested)
Timeline
- 2026-05-23: Report submitted to Automattic
- 2026-06-01: Triaged, severity downgraded to Low
- 2026-06-30: Follow-up sent
- 2026-08-13: Resolved with fix + $100 bounty