For WordPress developers · 2026

Why I got rejected from WordPress.org twice

July 2026 · ~10 min read

My plugin was declined twice by the WordPress.org review team. Not for weak features — for the common mistakes that Claude, or any high-level AI, can’t detect and quietly writes for you. This is from my real review emails, not a generic checklist. Make sure you’re not repeating them.

First, a warning: Plugin Check is the floor, not the finish line

I ran the Plugin Check (PCP) tool before submitting. It passed clean on most of the points below. The human reviewers flagged them anyway.

PCP matches patterns. It can’t tell whether your capability check guards the right record, whether your nonce runs before you read input, or whether data quietly leaves the site. Those are logic questions — and logic is what the human review audits.

After 12 years of client work I still got 6 flags in round one. That’s not embarrassing — that’s the process working.

01 Use %i for table names in prepare()

prepare() escapes values, never identifiers. My table names were dynamic — one lookup table per feature — so the name was built into the SQL string. To a reviewer, any interpolated identifier is an injection vector, full stop.

✗ Before

// table name concatenated into SQL
$wpdb->get_results(
  "SELECT * FROM {$table} WHERE id = {$id}" );

✓ After

// %i = identifier (WP 6.2+), %d = value
$wpdb->get_results( $wpdb->prepare(
  "SELECT * FROM %i WHERE id = %d",
  $table, $id ) );

Veteran habit: I also keep an allow-list of my own table names before the query. %i escapes the identifier — the allow-list rejects an unknown one. Do both.

02 Use WP_Filesystem, not raw PHP file calls

My attachment handling wrote files with raw PHP. That assumes the web server owns the filesystem — on hardened and managed hosts it often doesn’t. WP_Filesystem picks the right transport (direct, FTP, SSH) per host, so your write works everywhere or fails loudly.

✗ Before

// raw PHP — silently breaks on strict hosts
file_put_contents( $path, $data );

✓ After

require_once ABSPATH . 'wp-admin/includes/file.php';
WP_Filesystem();
global $wp_filesystem;
$wp_filesystem->put_contents(
  $path, $data, FS_CHMOD_FILE );

The reviewer’s point isn’t style — raw writes are the #1 source of “works on my server, corrupts on yours” support tickets.

03 Document every outside service you call

Any data leaving the site must be declared in readme.txt — what is sent, when, and links to that service’s Terms and Privacy policy. Yes, even Gravatar: a hashed customer email leaving the server is still customer data leaving the server.

✗ Before

// readme.txt
// (Gravatar, form + newsletter APIs
//  called in code, never mentioned)

✓ After

== External services ==
Gravatar — sends an MD5 hash of the
user's email to load avatars, on
every ticket view.
Terms: gravatar.com/tos
Privacy: automattic.com/privacy

Why reviewers care: a store owner must be able to answer “where does my customers’ data go?” without reading your source.

04 No “Powered by” on anything a customer sees

I had a small credit in the email footer. Rejected — branding on a user-facing surface is not allowed by default. The storefront and its emails belong to the merchant; your brand rides along only with their explicit consent.

✗ Before

// shown to every customer, always
$footer .= 'Powered by MyPlugin';

✓ After

// opt-in, default OFF, staff mail only
if ( get_option( 'oufw_credit', false )
  && $email_is_staff ) {
  $footer .= 'Powered by MyPlugin';
}

Growth tip that survives review: put the credit behind a setting and pitch it as “support the free plugin”. Some owners actually turn it on.

05 Check the nonce before reading input

I was verifying the nonce after assigning $_POST values. Same lines of code, wrong order. The principle: if a request is forged, your code should refuse it before touching a single byte of its payload. Anything else turns your CSRF guard into decoration.

✗ Before

// reads first, checks after
$note = $_POST['note'];
wp_verify_nonce( $nonce, 'oufw_note' );

✓ After

$nonce = sanitize_key( $_POST['_wpnonce'] ?? '' );
if ( ! wp_verify_nonce( $nonce, 'oufw_note' ) ) {
  return;
}
$note = sanitize_text_field(
  wp_unslash( $_POST['note'] ?? '' ) );

Note the pair reviewers expect together: wp_unslash() first, then the sanitizer. Skipping unslash corrupts quotes in real data. And reading $_POST['_wpnonce'] to verify it is the one allowed exception — that’s the check itself, not the payload.

06 Prefix options as one full literal

Two problems, one fix. Unprefixed keys like 'settings' collide with other plugins in a shared options table. And Plugin Check parses source statically — a runtime-built key like PREFIX . 'x' reads to the scanner as just 'x', so it flags you even when your runtime value is fine.

✗ Before

// scanner only sees 'settings'
get_option( self::PREFIX . 'settings' );

✓ After

// whole key as a single literal
const OPT = 'oufw_settings';
get_option( self::OPT );

Same rule for transients, meta keys, cron hooks and script handles. In a 60-plugin install, your prefix is your namespace.

07 Check permission per object, not by role alone

The reviewer’s sharpest catch. “Is this user an admin?” is the wrong question — the right one is “can this user edit this order?” Otherwise any logged-in user who changes order_id=1401 to 1402 in the request reads someone else’s order. That bug has a name: IDOR.

✗ Before

// role check only — locks out shop
// managers, ignores WHICH order
current_user_can( 'manage_options' );

✓ After

// capability + THIS object
current_user_can( 'manage_woocommerce' )
|| current_user_can(
     'edit_shop_order', $order_id );

All ~30 of my REST endpoints now run through one shared gate: verify X-WP-Nonce, then capability against the exact record. One gate, one place to audit.

08 Escape late — with core functions

A real case from my codebase. My icon helper escaped everything internally — and was flagged anyway. The reviewer (and the scanner) audit the echo site, where a function call is a black box. So you escape the output too.

The helper — already escaped inside

function dashicon( string $name, string $label ) {
  $class = 'dashicons-' . sanitize_html_class( $name );
  return sprintf( '<span class="%s" aria-label="%s">',
    esc_attr( $class ), esc_attr( $label ) );
}

✗ Before — trusted the helper, flagged

echo Icons::dashicon( 'edit', __( 'Edit' ) );

✓ After — provable at the echo, in one line

echo wp_kses_post(
  Icons::dashicon( 'edit', __( 'Edit' ) ) );

Fun casualty: wp_kses strips oklch() from inline CSS — my avatar colours went back to hex.

The quick ones — easy to fix, easy to forget

The best free security audit you’ll ever get

Two rounds felt slow in the moment. But the review caught things no automated tool will — including one real data-exposure bug. Save this and run through it before you submit. Which one would have caught you? Tell me below — I’ll share the fix that got mine approved.