WordPress CPTs and the REST API Read Permission Gap

2/18/2026

3 minutes read
wordpressrest-apiplaywrighttesting

You register a custom post type with 'public' => false and map every capability to a custom one. You expect unauthenticated users to be locked out entirely. Then you write a Playwright test, fire a GET /wp-json/wp/v2/your_cpt, and it returns 200 OK with an empty array.

What happened?

The Problem

WordPress treats REST API read permissions differently from write permissions. When you register a CPT with custom capabilities:

register_post_type('secret_note', [
    'public'       => false,
    'show_in_rest' => true,
    'capabilities' => [
        'edit_posts'   => 'manage_secret_notes',
        'create_posts' => 'manage_secret_notes',
        'delete_posts' => 'manage_secret_notes',
        // ... every cap mapped to manage_secret_notes
    ],
]);

Write endpoints (POST, DELETE) correctly check create_posts / delete_posts and return 401 for anonymous users. But GET requests go through WP_REST_Posts_Controller::get_items_permissions_check(), which allows read access as long as the query parameters are "safe." If you're only requesting published posts with no special filters, WordPress says "sure, go ahead" -- regardless of your capability mapping.

The reasoning is that public => false controls visibility in the admin UI and query defaults, not REST API read access. And show_in_rest => true explicitly opts the post type into the API. WordPress assumes that if you opted in, you want public reads.

That's a reasonable default for most post types. Not for ours.

The Fix: A Custom REST Controller

The solution is a custom controller that enforces your capability on read endpoints:

class PrivatePostController extends WP_REST_Posts_Controller {

    public function get_items_permissions_check($request) {
        $post_type = get_post_type_object($this->post_type);

        if (!current_user_can($post_type->cap->edit_posts)) {
            return new WP_Error(
                'rest_forbidden',
                __('Sorry, you are not allowed to access this resource.'),
                ['status' => rest_authorization_required_code()]
            );
        }

        return parent::get_items_permissions_check($request);
    }

    public function get_item_permissions_check($request) {
        $post_type = get_post_type_object($this->post_type);

        if (!current_user_can($post_type->cap->edit_posts)) {
            return new WP_Error(
                'rest_forbidden',
                __('Sorry, you are not allowed to access this resource.'),
                ['status' => rest_authorization_required_code()]
            );
        }

        return parent::get_item_permissions_check($request);
    }
}

Then wire it up during registration:

register_post_type('secret_note', [
    'rest_controller_class' => PrivatePostController::class,
    // ... rest of args
]);

Now both get_items (list) and get_item (single) check the mapped capability before proceeding. Anonymous users get 401. Authorized users fall through to the parent's check, which handles edge cases like post status and password protection.

Testing It with Playwright

This is where things get interesting. Our authenticated tests use @wordpress/e2e-test-utils-playwright, which provides a requestUtils fixture. This fixture handles authentication automatically -- it logs in as admin and attaches cookies/nonce to every request:

import { test, expect } from '@wordpress/e2e-test-utils-playwright';

test('REST API endpoint exists and responds', async ({ requestUtils }) => {
  const response = await requestUtils.rest({
    path: '/wp/v2/secret_note',
    method: 'GET',
  });
  expect(Array.isArray(response)).toBe(true);
});

But requestUtils always sends authenticated requests. You can't use it to test what an anonymous visitor sees.

Two Playground Instances

We run WordPress Playground as our test backend. The key insight: we spin up two separate instances with different blueprints.

The authenticated instance includes a login step in its blueprint:

const loginStep = [{ step: 'login', username: 'admin' }];

export const setupBlueprint = {
  preferredVersions: { php: '8.2', wp: 'latest' },
  steps: [...loginStep, ...debugStep, ...pluginStep],
};

The Playground login step creates a session cookie that @wordpress/e2e-test-utils-playwright picks up. Every requestUtils.rest() call sends this cookie, so WordPress sees an authenticated admin user.

The unauthenticated instance skips the login step entirely:

export const setupBlueprintNoLogin = {
  preferredVersions: { php: '8.2', wp: 'latest' },
  steps: [...debugStep, ...pluginStep],
};

No login, no cookie, no session. This instance runs on a different port.

Separate Test Projects

Playwright's config maps each instance to a test project:

export default defineConfig({
  webServer: [
    { command: 'pnpm playground:test' /* port 9401 */ },
    { command: 'pnpm playground:test-noauth' /* port 9402 */ },
  ],
  projects: [
    {
      name: 'authenticated',
      testDir: './tests/e2e',
      use: { baseURL: 'http://127.0.0.1:9401' },
    },
    {
      name: 'unauthenticated',
      testDir: './tests/e2e-unauth',
      use: { baseURL: 'http://127.0.0.1:9402' },
    },
  ],
});

Raw Playwright for Unauthenticated Tests

Since requestUtils always authenticates, the unauthenticated tests use Playwright's raw request API instead:

import { test, expect, request } from '@playwright/test';

test('listing posts should fail', async ({ baseURL }) => {
  const api = await request.newContext({ baseURL: baseURL! });
  const response = await api.get('/wp-json/wp/v2/secret_note');
  expect(response.status()).toBe(401);
  await api.dispose();
});

Note the import: @playwright/test, not @wordpress/e2e-test-utils-playwright. No fixtures, no auth, just a plain HTTP request against the no-login instance.

The Takeaway

WordPress REST API permissions have a split personality: writes check capabilities, reads often don't. If your CPT is private, 'public' => false alone won't protect the REST endpoints. You need a custom controller to close the gap -- and you need separate test environments to verify both sides of that gap.