Applies to forms.server-side-validation, forms.anti-spam.

Covers forms.server-side-validation, forms.anti-spam. Related: security.public-post-rate-limit, a11y.form-errors, legal.terms-consent-checkbox.

Client-side validation is a convenience for honest people and nothing else, so every rule the browser runs has to run again on an endpoint that is public and takes a POST from anything that can reach it.

The browser check is a courtesy, not a control

required, type=email, pattern and the validation module are there so an honest visitor gets a message beside the field instead of a round trip, which is forms.error-timing and a11y.form-errors doing their job. None of it survives contact with curl.

In WordPress the endpoint is a REST route registered with permission_callback => '__return_true', and it has to be: the sender is anonymous, so there is nobody to authorise. The nonce does not close that. A wp_rest nonce is cross-site request forgery protection, and wp-starter hands one out on a public GET /nonce with Cache-Control: no-store, because a nonce baked into cached HTML 403s every anonymous visitor while working for the logged-in developer. That is the right design, and it means a script can fetch a fresh one at will. One solved nonce buys unlimited submissions, which is why the third spam layer below is not optional.

Running every rule again, in WordPress

function site_contact_submit( WP_REST_Request $req ) {   // the route is public, see above
  $errors = [];
  foreach ( site_contact_fields() as $name => $field ) {  // the SAME map the markup renders from
    $value = site_sanitize_by_type( $req->get_param( $name ), $field['type'] );
    if ( $field['required'] && '' === $value )                   $errors[ $name ] = 'required';
    elseif ( 'email' === $field['type'] && ! is_email( $value ) ) $errors[ $name ] = 'format';
  }
  if ( $errors ) return new WP_Error( 'invalid', '', [ 'status' => 400, 'fields' => $errors ] );
  // only now: store, then notify
}

Presence, declared type, length, format and every consent, before anything is stored or emailed. An unticked terms box is one of these checks too, and a blocker of its own in legal.terms-consent-checkbox. Reading the list off the same field map the markup renders from is the whole point of wp-starter's form registry, and it is what stops the two drifting.

Both builds this standard was measured against pass this rule's acceptance test, and both still have exactly one field the markup marks required and the server never checks - a different field in each, and the phone pattern re-run in neither. The test names two fields, so it is a spot check: a build passes it with a required field wide open. Do not over-correct either, because inventing checks the browser never ran refuses real customers, which is what forms.value-formats exists to stop.

Three layers, and what each one misses

The client checklist behind this rule asks for a honeypot or a captcha, plus rate limiting. The rule requires all three, and its citation reads in part for that reason: it is stricter than the document it rests on, and says so rather than letting the client's name carry it.

Layer What it stops What it does not
Honeypot Bots that fill every input they find, which is most of the volume Anything that reads your CSS, and any script written against your form
Scored gate Cheap automation at volume, by scoring the session before you trust it A person paid to fill it in, a solved-challenge farm, and nothing at all while its verifier is unreachable and it fails open
Per-IP rate limit A script pointed at the endpoint, however well it handles the other two A distributed source, and one determined person submitting slowly

A refusal by any layer writes nothing: no message record, no consent record, no notification. And only a genuine validation failure may say what went wrong - wp-starter's honeypot answers a plain 200 that stores nothing, because a 400 naming the hidden field tells the script to leave it blank.

A captcha on its own is neither enough nor free

Incomplete. No clause in this standard's citation ledger names CAPTCHA, and since the move to ASVS 5.0.0 no clause in ASVS names it either - the word appears nowhere in the 5.0.0 text. The 4.0.3 release did name it, in company: "Such controls include blocking the most common breached passwords, soft lockouts, rate limiting, CAPTCHA, ever increasing delays between attempts". One item on a list, in a clause scoped to authentication. 5.0.0 dropped that list along with the rest of its mechanism naming and asks instead that the control be documented and then implemented as documented. Either way a captcha is one way of meeting the requirement and was never the requirement.

Fails open by default. wp-starter's reCAPTCHA v3 is optional, off by default, and returns a pass when the call to Google fails at the transport layer. Both delivered builds do the same. So on the day Google is unreachable from the host the gate is not there, nothing in the response says so, and the accept criterion tests exactly that case.

The cost lands on the visitor, and this part is our position rather than a citation. An interactive challenge is a task somebody must pass before they may send an enquiry, and those likeliest to fail are already having the hardest time: screen reader users, anyone at high magnification, anyone reading a machine translation. A scored gate shows no puzzle and trades that for a quieter failure - it refuses silently, with no route to succeed, and you cannot tell a false positive from a bot because nothing is recorded on the losing side.

The rate limit nobody ships

It is absent from wp-starter and from both delivered builds; the searches for it returned unrelated prose and, in one theme, an SVG path coordinate. A transient counter is enough: there is no CDN and no WAF in front of these sites, so if the application does not count, nothing does.

$key  = 'rl_contact_' . hash( 'sha256', $ip . wp_salt() );   // a counter, not a record
$hits = (int) get_transient( $key ) + 1;                     // count THIS one before deciding
if ( $hits >= 20 ) return new WP_Error( 'too_many', '', [ 'status' => 429 ] );
set_transient( $key, $hits, MINUTE_IN_SECONDS );

Count the current request before deciding, or the twentieth POST is the one that gets through and only the twenty-first is refused - which is not what the acceptance test asks for. It reads "20 POSTs from one IP inside a minute get a 429 before the last one", so nineteen succeed and the twentieth is the refusal.

set_transient re-arms the expiry on every write, so that is a sliding window: nineteen inside any sixty seconds with no gap. Stricter than a fixed window, which is the right direction here, but do not sell it to a client as "twenty a minute". It is also not double-submit protection - twenty is a long way above two, and both copies of a replayed POST land inside the window, which is forms.submit-lifecycle's problem and needs an idempotency key.

Hash the address and let it expire. security.public-post-rate-limit needs the IP in order to count, while this standard refuses to store one in a consent record, since that record is kept for years and an address is directly identifying personal data. A salted key that dies in sixty seconds is a control; a column in a table you keep is a data set.