Registration Form Fields for WooCommerce: How Unapproved Accounts Got Past Admin Approval

• by Tobias Schäfer • 8 min read

On a client’s WooCommerce shop, customer accounts started showing up that nobody had approved. They had an email address and nothing else: no name, no company, no address, although the registration form requires all of that. They already carried the Customer role and could log in. The shop uses Registration Form Fields for WooCommerce with admin approval, so this shouldn’t have been possible.

The cause was a gap between the plugin and WooCommerce. The plugin only enforces its rules when its own hidden form fields are in the request. WooCommerce’s built-in registration handler still accepts a plain request without them, and the plugin let that request through unchecked.

I reported it to the vendor, WPEverest, on 22 October 2026. Version 2.1.3 fixes it, and 2.1.4 is the latest release. If you use this plugin, update. The rest of this post explains what went wrong, how to check whether your shop was hit and what to do if you can’t update right away.

Who is affected

PluginRegistration Form Fields for WooCommerce (slug custom-registration-form-fields-builder-for-woocommerce), sold through the WooCommerce.com Marketplace
VendorWPEverest (listed as ThemeGrill on WooCommerce.com and in the plugin header)
Confirmed vulnerable2.1.2
Earlier versionsProbably affected (same code path), not tested
Fixed in2.1.3 (latest on WooCommerce.com: 2.1.4)
PreconditionA registration form is set as the My Account form, which is the plugin’s normal setup
Not requiredWooCommerce’s “Allow customers to create an account on My Account” setting; the attack works with it off

Shops that use the form with admin approval or email confirmation are hit hardest, because that is exactly the step that gets skipped.

How the plugin and WooCommerce fit together

The plugin has no registration endpoint of its own. It replaces the form on the My Account page and leaves the actual account creation to WooCommerce.

  • WooCommerce handles registration in WC_Form_Handler::process_registration(), hooked on wp_loaded. It runs on any URL for any POST that carries register, email and a valid woocommerce-register-nonce. If no password is sent, it generates one. After creating the user, it logs them in.
  • The plugin adds its form fields plus two hidden inputs: tgwcfb-register-nonce and tgwcfb_id (the form’s post ID). On woocommerce_register_post it validates the fields, checks the captcha and, if the form uses admin approval or email confirmation, stores the user meta user_status = pending. For those forms it also turns off WooCommerce’s automatic login after registration.
  • At login, UserApproval::check_status_on_login() refuses only pending and denied. A user with no user_status at all is treated as approved, and the admin user list shows them as approved too.

One more detail matters: WordPress nonces for logged-out visitors are the same for every visitor and stay valid for 12 to 24 hours. Both nonces can be read from the public My Account page.

The bypass: a plain WooCommerce registration

A registration request without the plugin’s two hidden inputs creates a logged-in customer with no approval status. Every check in the plugin sits behind this early return in FormHandler::validate_fields() (2.1.2):

if (
	! isset( $_POST['tgwcfb-register-nonce'] ) ||
	! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['tgwcfb-register-nonce'] ) ), 'tgwcfb-register' ) ||
	! isset( $_POST['tgwcfb_id'] ) ||
	is_checkout()
) {
	return;
}

If the request leaves out tgwcfb-register-nonce, the function returns without adding an error. WooCommerce sees no error and creates the account. As a result:

  • required fields (name, company, address) are never checked
  • the captcha is never checked
  • user_status is never set, so the account counts as approved
  • the login after registration stays on, so the response already carries a valid session

The request only needs fields that are public on the My Account page:

POST /my-account/
email=<any address>
woocommerce-register-nonce=<value from the public page>
register=Register

I reproduced it on a local copy of the affected shop (WordPress, WooCommerce, the plugin and the shop’s data). On the live shop, several accounts had been created exactly this way, with nothing but an email address.

The first fix wasn’t enough

The vendor’s first fix candidate required the plugin’s nonce, but then accepted any tgwcfb_id that pointed to a form post with content. It didn’t check whether that form was the configured one or whether it was published.

// first fix candidate, simplified
if ( ! wp_verify_nonce( $nonce, 'tgwcfb-register' ) || empty( get_blocks( $form_id ) ) ) {
	$errors->add( ... );
}

get_blocks() only checks the post type, so a trashed, draft or old form passes. On the affected shop, the original default form was in the trash. It asked only for username, email and password and had no approval setting. Sending its ID created an account without user_status that could log in. Same result as before.

Form IDs aren’t secret. They are sequential post IDs, and the error for a wrong ID differs from the success response, so a bot can find a working ID by counting up. I reported this to the vendor before the final release, and the final build closes it.

Impact and severity

The attacker gets a customer account the shop owner never approved. What that account can reach depends on the shop. If approval gates prices, ordering, downloads or a B2B catalogue, that gate is gone. On any shop, the required fields and the captcha no longer filter out bots, so spam accounts pile up.

The account gets the role the form would assign (customer by default). The attacker can’t pick a higher role this way, and no existing data is read or changed.

My own estimate is CVSS 3.1 AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N, base score 5.3 (Medium): one unauthenticated HTTP request, all inputs on a public page, no data exposed by the flaw itself, but an access-control step bypassed. Shops where approval protects sensitive content may rate it higher. Whether a CVE gets requested is still open.

What 2.1.3 changes

Version 2.1.3 adds FormHandler::require_registration_form() on WooCommerce’s woocommerce_process_registration_errors filter. When a My Account form is configured, every registration must carry a valid plugin nonce and a form ID that the site actually exposes:

  • the configured My Account form or checkout form, whatever its status, or
  • a published form embedded with the [tgwcfb_registration_form] shortcode in published content or a widget.

Anything else is refused with “Please register using the registration form.” The list of exposed forms is cached in a transient and cleared whenever posts, widgets or the two form settings change. If a page builder embeds your form in a way the plugin doesn’t detect, the tgwcfb_registration_form_ids filter lets you add its ID.

If no My Account form is configured, the check does nothing. That’s intended: in that case the plugin isn’t in charge of registration.

I tested the vendor’s final build on the local copy of the shop, with none of my own mitigations active:

RequestResult
Plain WooCommerce registration (the bypass)Refused
Configured form, all fieldsAccount created as pending, password login refused
Trashed default form ID, few fields (first fix gap)Refused
Trashed default form ID, all fieldsRefused
ID of a private page, not a formRefused
Form ID that does not existRefused

That build still had 2.1.2 in its plugin header. After updating, check that your plugin list shows 2.1.3 or later.

If you can’t update yet

Until you can update, this must-use plugin closes the gap. Save it as a PHP file in wp-content/mu-plugins/. It accepts only registrations that carry the plugin’s nonce and the configured My Account form ID. If the plugin is deactivated, it blocks My Account registration entirely instead of failing open. You can leave it in place after updating.

<?php
/**
 * Plugin Name: Enforce Registration Form
 * Description: Rejects My Account registrations that do not come from the configured registration form.
 */
defined( 'ABSPATH' ) || exit;

add_filter(
	'woocommerce_process_registration_errors',
	function ( $errors ) {
		$nonce   = isset( $_POST['tgwcfb-register-nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['tgwcfb-register-nonce'] ) ) : '';
		$form_id = isset( $_POST['tgwcfb_id'] ) ? absint( wp_unslash( $_POST['tgwcfb_id'] ) ) : 0;

		if ( ! wp_verify_nonce( $nonce, 'tgwcfb-register' ) || absint( get_option( '_tgwcfb_form_id' ) ) !== $form_id ) {
			$errors->add( 'registration_form_required', 'Please register using the registration form.' );
		}

		return $errors;
	}
);

If your shop uses a separate checkout form, add _tgwcfb_checkout_form_id to the check.

Was my shop hit?

Look for customers registered after the form went live that have neither user_status nor tgwcfb_form_id user meta. Accounts with only an email address and no name are the typical pattern. Check each one by hand before deleting it, because accounts created by an admin or at checkout match too.

SELECT u.ID, u.user_login, u.user_email, u.user_registered
FROM wp_users u
WHERE NOT EXISTS (SELECT 1 FROM wp_usermeta m WHERE m.user_id = u.ID AND m.meta_key = 'tgwcfb_form_id')
  AND NOT EXISTS (SELECT 1 FROM wp_usermeta m WHERE m.user_id = u.ID AND m.meta_key = 'user_status')
  AND EXISTS (SELECT 1 FROM wp_usermeta m WHERE m.user_id = u.ID AND m.meta_key = 'wp_capabilities' AND m.meta_value LIKE '%"customer"%')
ORDER BY u.user_registered DESC;

Adjust the wp_ table prefix to your own.

This query misses accounts created through another form’s ID (the first-fix gap). Those carry tgwcfb_form_id but no user_status, so also list customers whose tgwcfb_form_id isn’t your configured form.

Disclosure timeline

DateEvent
2026-11-02Vendor sent the revised fix; I confirmed it closes both issues. Vendor approved publication
2026-10-29I found the first fix incomplete (a trashed form’s ID was still accepted) and reported it
2026-10-27Vendor sent the first fix candidate
2026-10-22Found while investigating unexpected customer accounts on a client shop; reported to WPEverest / ThemeGrill with root cause and proof of concept

Eleven days from report to verified fix. Thanks to the WPEverest team for the fix and for approving disclosure.

Related Posts