Skip to content
Pricing and Quotes

WooCommerce Hide Price Until Login: The Snippet and the Leaks It Misses

A practical guide to WooCommerce hide price until login: the conditional snippet, a role gate for trade buyers, and the API and cache leaks it misses.

You sell to trade buyers, or you negotiate every deal, or your distributor agreement says list prices must not appear in public. Whatever the reason, the requirement is the same: an anonymous visitor sees no numbers, and an approved customer who logs in sees prices and checks out normally. That is the WooCommerce hide price until login pattern, and it is one of the most common things a B2B shop needs WooCommerce to do that WooCommerce does not do on its own.

Search for it and you will find a four-line snippet in about ten seconds. The snippet is correct as far as it goes. What almost nobody mentions is that the price on the product page is one of at least half a dozen places WooCommerce publishes that number, and that a page cache can hand a logged-in render to a guest and undo all of it in a single request.

What “the price” actually is in WooCommerce#

The formatted price you see on a product page comes from WC_Product::get_price_html(). That is presentation. The number itself lives in the _price meta and the product lookup table, and WooCommerce republishes it through several independent code paths that never call get_price_html() at all.

  • The classic templates. Single product, shop loop, related products, grouped product rows, upsells.
  • The variation JSON. On a variable product, WooCommerce prints every variation’s price into a data-product_variations attribute so the form can switch without a request.
  • The Store API. Block themes and the cart/checkout blocks fetch /wp-json/wc/store/v1/products, which returns a prices object and a price_html string, and the collection-data endpoint returns an aggregated price range.
  • Structured data. WooCommerce emits JSON-LD with an offers.price field. So do Rank Math, Yoast, AIOSEO and most schema plugins, from their own code.
  • The REST API and GraphQL. wc/v3/products and WPGraphQL both expose price fields to anyone holding credentials for them.
  • Cart, mini-cart, checkout, order emails and My Account. These format line totals directly, not through get_price_html().

Filtering one of these does not touch the others. That single fact explains almost every “I hid the price but Google still shows it” support thread.

Start with a role gate, not with is_user_logged_in()#

Most tutorials wrap the price filter in is_user_logged_in(). That is fine if your shop has closed registration and you create every account by hand. If registration is open — a checkbox under WooCommerce › Settings › Accounts & Privacy that plenty of shops switch on — then “logged in” means “anyone who typed an email address”, which is not the audience you meant to show trade prices to.

Write one function that answers the question, and call it everywhere. Changing the policy later becomes a one-line edit instead of a hunt through your functions.php.

<?php
/**
 * The single source of truth for price visibility.
 */
function trade_can_see_prices() {
	if ( ! is_user_logged_in() ) {
		return false;
	}

	$allowed = array( 'administrator', 'shop_manager', 'trade_customer' );
	$user    = wp_get_current_user();

	return (bool) array_intersect( $allowed, (array) $user->roles );
}

Add the trade_customer role once with add_role() in an activation hook, or reuse the default customer role if you approve every registration manually. Either way, approval is now an explicit act rather than a side effect of signing up.

Now the display filter:

<?php
add_filter( 'woocommerce_get_price_html', 'trade_price_html', 100, 2 );
add_filter( 'woocommerce_variable_price_html', 'trade_price_html', 100, 2 );
add_filter( 'woocommerce_variable_empty_price_html', 'trade_price_html', 100, 2 );

function trade_price_html( $price_html, $product ) {
	if ( trade_can_see_prices() ) {
		return $price_html;
	}

	return '<span class="price-on-request">'
		. esc_html__( 'Price on request', 'trade' )
		. '</span>';
}

Priority 100 matters. Run late so that currency switchers, tax display filters and discount plugins have already had their turn and you are the last word.

Variable products deserve a correction to something the snippets usually imply. WC_Product_Variable::get_price_html() builds its range, passes it through woocommerce_variable_price_html (or woocommerce_variable_empty_price_html when no variation has a price), and then hands the result to woocommerce_get_price_html exactly like every other product type. The first filter alone already covers the range. The two variable-specific hooks above are belt and braces for themes and plugins that call those paths directly, not a requirement. The rest of the variable-product story is in hiding the price range on variable products.

Hiding a price is not closing the checkout#

With only the filter above, a guest sees “Price on request” and an Add to Cart button that still works. They can add the product, reach the cart, and read the exact price in the line total. The cart never asked get_price_html() for permission.

The blunt fix is to make the product unpurchasable for guests. This is one filter, and it is respected by classic templates, the block-based Add to Cart, and the Store API’s cart write endpoints — which is why it beats hiding the button with CSS:

<?php
add_filter( 'woocommerce_is_purchasable', function ( $purchasable, $product ) {
	return trade_can_see_prices() ? $purchasable : false;
}, 100, 2 );

If you want the storefront to keep working as a browsable catalogue for guests, with a contact route instead of a cart, the longer version of this is in WooCommerce catalog mode without a plugin and hiding the Add to Cart button.

Why WooCommerce hide price until login is not one filter#

Here are the leaks, in the order people usually discover them, each with the filter that closes it. Test each one by logging out and looking at the raw response, not at the rendered page.

The variation JSON#

View source on a logged-out variable product page and search for display_price. Every variation’s price is sitting in the HTML, because the variation form needs it client-side.

<?php
add_filter( 'woocommerce_available_variation', function ( $data ) {
	if ( trade_can_see_prices() ) {
		return $data;
	}

	$data['price_html']            = '';
	$data['display_price']         = 0;
	$data['display_regular_price'] = 0;

	return $data;
} );

Use 0 rather than an empty string for the two numeric keys. Some themes run the value through a JavaScript number formatter and throw a console error on an empty string, which breaks the whole variation form.

Structured data#

WooCommerce core prints a Product JSON-LD block with an offers node. That is what search engines read, and it is generated independently of the visible price.

<?php
add_filter( 'woocommerce_structured_data_product', function ( $markup ) {
	if ( trade_can_see_prices() ) {
		return $markup;
	}

	unset( $markup['offers'] );

	return $markup;
} );

This handles core only. If you run Rank Math, Yoast, All in One SEO or a dedicated schema plugin, each one builds its own graph with its own filter name, and you have to close each separately. Check the rendered source of a logged-out page for the string "price" and count how many times it appears.

The Store API and the REST API#

This is the one that surprises people who switched to a block theme. Product Collection and the cart and checkout blocks do not render prices in PHP; they fetch JSON from /wp-json/wc/store/v1/ and render in the browser. Your woocommerce_get_price_html filter never runs.

wc/v3 is a different shape of risk, and it is worth being precise about it. That route is not public: an unauthenticated GET /wp-json/wc/v3/products returns a 401. But every consumer key you ever issued — to a stock sync, an accounting bridge, a mobile app, an agency that finished the project two years ago — reads the full price, regular_price and sale_price fields, and keys routinely outlive the integration they were made for. Hiding prices in the storefront while leaving five live keys in WooCommerce > Settings > Advanced > REST API is a policy decision, so make it deliberately.

You can intercept the Store API and the REST API from one place with a core WordPress filter:

<?php
add_filter( 'rest_post_dispatch', function ( $response, $server, $request ) {
	if ( ! $response instanceof WP_REST_Response || trade_can_see_prices() ) {
		return $response;
	}

	$prefixes = array( '/wc/store/', '/wc/v3/products', '/wc/v2/products' );
	$route    = $request->get_route();
	$match    = false;

	foreach ( $prefixes as $prefix ) {
		if ( 0 === strpos( $route, $prefix ) ) {
			$match = true;
			break;
		}
	}

	if ( ! $match ) {
		return $response;
	}

	$response->set_data( trade_blank_prices( $response->get_data() ) );

	return $response;
}, 10, 3 );

function trade_blank_prices( $data ) {
	$keys = array(
		'price', 'regular_price', 'sale_price',
		'price_html', 'min_price', 'max_price',
	);

	foreach ( (array) $data as $key => $value ) {
		if ( is_array( $value ) ) {
			$data[ $key ] = trade_blank_prices( $value );
		} elseif ( in_array( $key, $keys, true ) ) {
			$data[ $key ] = '';
		}
	}

	return $data;
}

This is deliberately blunt. It walks the whole response and blanks anything that looks like a price, including the aggregated price range used by price-filter blocks. Test it, because blanking a field the block editor expects can make a block render empty rather than render without a price. Two things to know before you assume the route is sealed: a consumer key authenticates as its owning user, so a key owned by an administrator still passes trade_can_see_prices() and still returns real numbers — usually what you want, occasionally not. And if you also expose WPGraphQL, that is a third schema with its own resolvers and needs its own pass.

If maintaining five filters plus a schema-plugin sweep is more than you want in functions.php, this is the set of surfaces PriceVeil for WooCommerce closes server-side. One distinction matters for this article: the free audience setting offers Everyone or Guests only, and Guests only is the plain logged-in gate — any logged-in customer sees prices and buys normally. It is not the role gate above; restricting price visibility to chosen roles is a Pro targeting rule. On the free version, who gets an account is still enforced by your registration policy, exactly as it is with the snippet. What the free version does cover is the surface list: storefront, the variation JSON, the Store API, core and third-party structured data (Rank Math, Yoast, All in One SEO, Schema & Structured Data for WP), the WooCommerce REST API and GraphQL. The documentation sets out what is handled automatically and what you still have to check yourself, which is the useful half if you have already patched some of these by hand.

The caching trap#

Every filter above depends on wp_get_current_user(), which means the output of a product page now depends on who asked for it. A full-page cache that does not know this will store one render and serve it to everyone.

The failure has two directions and only one of them is harmless. A guest render cached and served to a logged-in trade customer is annoying: they see “Price on request” until they hit the cart. A logged-in render cached and served to guests is the failure that breaks WooCommerce hide price until login completely, because your real prices are now sitting in a CDN edge node with a long TTL.

Three configurations cause it in practice:

  • Cache Everything without a cookie bypass. A Cloudflare page rule or transform that caches HTML must be paired with a bypass on wordpress_logged_in_*. Without it the first authenticated request populates the public cache.
  • Warm-up crawlers running as a user. Some cache-preload tools can be pointed at a logged-in session. If yours can, make sure it is not.
  • Fragment or object caching of price HTML. If a theme or optimisation plugin caches the shop loop markup under a key that does not include the user’s role, the loop is shared across audiences even when the page is not.

The safest arrangement is the plain one: cache the guest version aggressively, never cache responses carrying a login cookie, and make sure the Store API and REST routes are excluded from HTML and CDN caching entirely. Verify it the boring way — open a private window, request the product page, and read the raw HTML rather than trusting the rendered page. Then request /wp-json/wc/store/v1/products?per_page=100 in that same window and search the JSON for a digit. Our write-up on how we leak-test WooCommerce plugins goes through the same checklist in more detail; PriceVeil automates part of it as wp priceveil selftest, which walks every product and asserts that the price HTML, the structured data, the Store API product response and the collection price range come back blank, exiting non-zero on any leak.

Coverage at a glance#

SurfaceCovered by the price_html filter aloneWhat closes it
Single product, shop loop, relatedYeswoocommerce_get_price_html
Variable product price rangeYeswoocommerce_get_price_html (variable hooks optional)
Inline variation JSONNowoocommerce_available_variation
Store API products and price rangeNoREST response filter
Store API cart writesNowoocommerce_is_purchasable
Core JSON-LD offersNowoocommerce_structured_data_product
SEO plugin schemaNoOne filter per plugin
REST wc/v3 and wc/v2 (key holders)NoREST response filter, plus key hygiene
WPGraphQL / WooGraphQLNoResolver-level filtering
Cart, checkout, order emailsNoBlock purchase, or filter each total
Cached HTML served to the wrong audienceNoCache rules, not PHP

What this approach does not solve#

Being honest about the edges is more useful than a longer feature list.

  • Product feeds. CTX Feed, Google Product Feed and merchant-centre integrations build their files from the product object, not from your filters. They will keep exporting real prices. Turn them off, or accept that your catalogue is public through that channel.
  • Theme and custom code. Anything that calls $product->get_price() and echoes the result bypasses every display filter by design. A grep of your child theme for get_price takes a minute and is worth doing.
  • Pages already indexed. Filters change what is served from now on. Prices Google crawled last month stay in its cache and in third-party price-comparison scrapers until they recrawl.
  • Third-party edge caches. Activating any of this does not purge an object already sitting in a CDN. Purge once, manually, after the change.
  • Registration policy. A role gate only means something if you control who gets the role. If anyone can register and be approved automatically, you have hidden prices from crawlers and from nobody else.

What to do next#

Work in this order. Add the trade_can_see_prices() helper and the display filter. Decide whether guests should be able to buy at all, and if not, add the woocommerce_is_purchasable filter rather than CSS. Close the variation JSON, the structured data and the REST routes, then review your live REST keys. Then check your cache configuration, because a WooCommerce hide price until login setup is only as reliable as the layer in front of it.

Finally, decide what a guest should do instead of buying. A product page that says “Price on request” with no next step gives the visitor nowhere to go. Adding a request-a-quote flow in the place where the Add to Cart button used to be is the usual answer, and it gives you the email address you needed to approve the account in the first place.

Keep reading

Related articles