Checkout Bouncer

Notes

Duplicate and rogue WooCommerce checkout pages: how they happen and how to find them

WooCommerce treats one page as the checkout. Any other page carrying the form can still place orders, and a captcha wired to the assigned page never sees them.

7 min read

A duplicate WooCommerce checkout page is not a rumour: a store can have more than one page that will take an order. Not a page that links to checkout, but one that renders the checkout form, collects a card, and creates a real order. It is easy to miss, because it is not the page you open when you test your store.

WooCommerce designates one checkout page, but any page can place an order. A duplicate WooCommerce checkout page rarely looks broken, which is what makes it awkward. To see why, separate two things that sound like one: the setting that names the checkout page, and the endpoint that processes orders.

What WooCommerce means by the checkout page

Under WooCommerce, Settings, Advanced, the Page setup section has a field called Checkout page. The plugin source describes it as "Page where shoppers go to finalize their purchase". That dropdown writes one numeric post ID into the option woocommerce_checkout_page_id. Anything asking which page is the checkout reads that number through wc_get_page_id( 'checkout' ), which returns the option value or -1.

The conditional tag is more generous. is_checkout() returns true if the woocommerce_is_checkout filter says so, if the WOOCOMMERCE_CHECKOUT constant is defined, or if CartCheckoutUtils::is_checkout_page() agrees. That helper tests the assigned page ID first, then inspects the current post for the woocommerce_checkout shortcode or the classic shortcode block. A second page carrying the shortcode is therefore recognised, but only if the shortcode really sits in post_content. Layout kept in post meta by a builder is invisible to it.

Links behave differently. WooCommerce builds its own checkout links from the assigned page, and the order received test wants that page ID and the order-received query variable together. One page is the checkout for navigation. Several can be a checkout for rendering.

How a duplicate WooCommerce checkout page comes to exist

The most dependable way to grow a duplicate WooCommerce checkout page is to break the option and let WooCommerce heal itself. wc_create_page() checks the stored option first. It accepts that option only if the page exists and is not pending, trashed, scheduled or an auto draft. If that fails, it runs a LIKE query across post_content hunting for the shortcode, then falls back to the slug. It inserts a new page only when all of those come up empty.

Now consider what defeats each check. A migration or import assigns new post IDs, so the stored number can point at the wrong post or at nothing. A rebuild moves the checkout into a builder, the shortcode leaves post_content, and the query misses. Page creation runs at installation, and the Create pages button under WooCommerce, Status, Tools "creates any default WooCommerce pages that are missing from your site". WooCommerce cannot find a checkout, so it makes one, and now there are two.

The rest of the ways a duplicate WooCommerce checkout page appears are human. An old design stays published in case someone wants it back. Somebody clones a landing page to trial a layout and never unpublishes it. WordPress statuses are blunt: published pages are "Viewable by everyone", while drafts need a suitable role. The published ones nobody links to are the ones that matter.

Why a duplicate WooCommerce checkout page still takes real orders

Placing an order does not travel through the page. The classic checkout form posts to a site wide endpoint. WooCommerce reads the wc-ajax query variable and fires the matching wc_ajax_ action. The checkout handler is three lines: it defines the WOOCOMMERCE_CHECKOUT constant and calls process_checkout(). The nonce it verifies is printed by whatever rendered the form, so any page with the form has a valid nonce.

Two things follow. The order is created regardless of which page the shopper was on. And because that request defines the constant, is_checkout() is true throughout processing. So "am I on the checkout?" gets one answer while a page renders and another when it submits.

Block checkout has its own route. The Store API sits under wc/store/v1, and a POST to its checkout route converts a cart into an order. The Checkout block has no page ID gate in its render path, so it renders wherever it is placed. Both routes are global, and neither asks which page sent the request, which is why protection has to sit on every route rather than on one page.

Why a captcha on the assigned page misses the copy

reCAPTCHA v3 returns a score for each request without user friction, on a scale where "1.0 is very likely a good interaction, 0.0 is very likely a bot". The browser mints a token, your server posts it to Google's verification API, and a score comes back. No token means no score.

That token comes from JavaScript which has to be on the page. If an integration decides where to load its script using is_page( wc_get_page_id( 'checkout' ) ), it loads nowhere else. This is not hypothetical: a WooCommerce issue reported a secondary page using the checkout shortcode where scripts failed to fire, because the check matched only the configured page ID.

From there it breaks one of two ways. If validation is gated on the same page test, it is skipped, so orders from the copy are never scored. If validation always runs, it finds no token, so honest orders are rejected and nobody can work out why. A third gap sits on the block side. Legacy actions from the shortcode flow do not fire on Store API requests, so a check hooked only to classic actions never runs for a block checkout.

How to find them, and what to do next

No single method finds every duplicate WooCommerce checkout page, so work through all of these.

  1. Note the assigned page under Settings, Advanced, Page setup, and record its page ID.
  2. Read WooCommerce, Status, System status. Its pages section flags problems such as "Page ID is set, but the page does not exist", and warns when a page holds both the shortcode and the block. It judges only the assigned page, so it is a health check, not a hunt.
  3. Search the Pages list for woocommerce_checkout. WordPress search covers post_title, post_excerpt and post_content. Repeat for wp:woocommerce/checkout to catch block markup.
  4. Change the status filter: draft, pending, private and trash, not only published. Trashing is not disposal: wc_create_page() will restore a matching trashed page rather than insert a new one.
  5. List everything with WP-CLI: wp post list --post_type=page --post_status=any --fields=ID,post_title,post_status --format=csv. It takes any WP_Query argument, so --s=woocommerce_checkout searches content.
  6. Read your sitemap at /wp-sitemap.xml, published by WordPress core for public sites. It shows what a stranger can reach.
  7. List pages from the command line with WP-CLI's post list command, which accepts any WP_Query argument and so doubles as a content search.
  8. Check the builder's storage. When layout lives in post meta, the searches above will not see it, so export the page or query meta directly.
  9. Read the access log. Find POST requests to the wc-ajax=checkout endpoint and the Store API checkout route, then group them by referrer.

Then choose one canonical checkout page and assign it. For the rest, take the checkout form out of the page rather than merely hiding the page. Dropping a page from a menu changes nothing about who can reach it by URL. Keep the cart, checkout and account pages distinct too. WooCommerce documents that pointing them at one page causes incorrect redirects and broken payment gateways, and has prevented it since version 3.7.

Where Checkout Bouncer fits

Checkout Bouncer is a free, GPL plugin on WordPress.org that does both halves of this job, and the Checkout Scanner is the half that goes looking. It scores checkout submissions with Google reCAPTCHA v3. It also searches the site for other pages that can render a checkout form, including the ones a builder stores outside post_content. It is deliberately narrow: it covers the WooCommerce checkout only, not login, registration or contact forms, and it is neither a firewall nor a malware scanner. A Pro tier is planned and does not exist, so there is nothing to buy. If you would rather run the audit by hand, the steps above work perfectly well.

See which doors into your checkout are standing open