I had a private WordPress site that needed the REST API locked down. Nothing critical, just work stuff. I didn’t want data leaking. Blocking it is simple: hook into rest_authentication_errors, return a 401 for anyone who isn’t logged in.
Done. Except not done.
The site had password-protected posts with custom interactive blocks that fire REST API requests dynamically. When you enter the post password, WordPress sets a wp-postpass_* cookie. The REST API doesn’t know about that cookie. It sees an unauthenticated request and returns 401. Blocks break for visitors who legitimately entered the password.
So I needed an exception, and I built one. Probably the wrong way too ¯\_(ツ)_/¯
It ended up being two layers. The first is at rest_authentication_errors. That’s the gate. At this point you have no idea which post the user wants, you just need to know: do they have any valid postpass? So you grab all the unique post passwords from the database, run the WordPress cookie hash through PHPass’s CheckPassword() against each one.
If you get a match, let them through.
The second layer is WordPress’s capability system, specifically map_meta_cap / user_has_cap filters. That’s where the per-post check happens. WordPress already does this for password-protected posts on the frontend, so you can lean on existing behavior instead of reinventing it.
Caching matters here, obviously. The password list query hits the DB on every REST request without it, and if you have a lot of passwords that PHPass loop gets slow.
I ended up with dual caching: the list and per-cookie validation results.
WordPress 6.9 added wp_cache_get_salted() / wp_cache_set_salted() and you should use them.
The old approach of hashing wp_cache_get_last_changed() with the cache key did its job, but it was junk. Every invalidation left dead unreachable keys sitting in your object cache forever.
The new functions reuse the same key and do an in-memory comparison instead. Less garbage (even though I do love garbage).
For invalidation I built a custom postpass last-changed group that only busts the cache when post passwords actually change, not on every post save. Using the broader posts group would’ve nuked the cache hit rate.
Here’s the whole thing:
<?php
/**
* Get the list of public post statuses for postpass queries.
*
* Only public statuses matter — draft/private posts require login,
* so their passwords are irrelevant for anonymous REST API access.
*
* @return string[] Array of public post status slugs.
*/
function postpass_public_statuses(): array {
return array_values( get_post_stati( array( 'public' => true ) ) );
}
/**
* Validate a post password cookie against all password-protected posts.
*
* This function checks if a cookie hash matches any post password in the database.
* Results are cached to avoid expensive re-validation on subsequent requests.
*
* @param string $cookie_hash The PHPass hash from the wp-postpass cookie.
* @return bool True if the cookie matches any post password, false otherwise.
*/
function validate_postpass_cookie_against_all_posts( string $cookie_hash ): bool {
/**
* Early validation: Check if cookie is valid PHPass format
* WordPress uses PHPass which produces hashes like: $P$B... (34 chars)
*/
if ( ! preg_match( '/^\$P\$[A-Za-z0-9.\/]{31}$/', $cookie_hash ) ) {
return false;
}
// Check object cache first to avoid repeated expensive validation.
$cache_key = 'postpass_valid_' . md5( $cookie_hash );
$cache_group = 'postpass_validation';
$cache_salt = wp_cache_get_last_changed( 'postpass' );
$cached = wp_cache_get_salted( $cache_key, $cache_group, $cache_salt );
// wp_cache_get_salted() returns false on cache miss, so we store the string
// 'false' for negative results. Decode it here — (bool) 'false' is true in PHP.
if ( false !== $cached ) {
return 'false' === $cached ? false : (bool) $cached;
}
global $wpdb;
$passwords_cache_key = 'post_passwords';
$passwords = wp_cache_get_salted( $passwords_cache_key, $cache_group, $cache_salt );
if ( false === $passwords ) {
/**
* Query all unique post passwords from publicly-accessible posts.
* Using DISTINCT because multiple posts may share the same password.
*/
$statuses = postpass_public_statuses();
$placeholders = implode( ', ', array_fill( 0, count( $statuses ), '%s' ) );
$passwords = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
"SELECT DISTINCT post_password FROM {$wpdb->posts} WHERE post_password != '' AND post_status IN ({$placeholders})",
$statuses
)
);
wp_cache_set_salted( $passwords_cache_key, $passwords, $cache_group, $cache_salt );
}
if ( empty( $passwords ) ) {
// Store 'false' string — wp_cache_get_salted() returns false on miss, so a boolean false is indistinguishable.
wp_cache_set_salted( $cache_key, 'false', $cache_group, $cache_salt );
return false;
}
// Check cookie hash against each unique password using PHPass.
if ( ! class_exists( '\PasswordHash' ) ) {
require_once ABSPATH . WPINC . '/class-phpass.php'; // @codeCoverageIgnore
}
$hasher = new \PasswordHash( 8, true );
foreach ( $passwords as $password ) {
/**
* CheckPassword() re-hashes the plaintext password with the salt
* embedded in the cookie hash and compares the results
*/
if ( $hasher->CheckPassword( $password, $cookie_hash ) ) {
/**
* Match found! This cookie is valid for at least one post
* Cache positive result for 1 hour
*/
wp_cache_set_salted( $cache_key, true, $cache_group, $cache_salt );
return true;
}
}
// No match found - cookie doesn't match any post password, cache negative result.
wp_cache_set_salted( $cache_key, 'false', $cache_group, $cache_salt );
return false;
}
/**
* Invalidate the postpass cache group when the set of public post passwords changes.
*
* Hooked to `wp_after_insert_post` so we see both $post (new) and $post_before (old).
*
* @param int $post_id Post ID.
* @param WP_Post $post Post object after the update.
* @param bool $update Whether this is an update (true) or new insert (false).
* @param WP_Post|null $post_before Post object before the update (null on insert).
*/
function postpass_maybe_invalidate_on_save( int $post_id, WP_Post $post, bool $update, ?WP_Post $post_before ): void {
$public_statuses = postpass_public_statuses();
if ( ! $update ) {
// New post: invalidate only if it has a password AND is in a public status.
if ( '' !== $post->post_password && in_array( $post->post_status, $public_statuses, true ) ) {
wp_cache_set_last_changed( 'postpass' );
}
return;
}
// Update: check if the public password set changed.
$was_public = in_array( $post_before->post_status, $public_statuses, true );
$is_public = in_array( $post->post_status, $public_statuses, true );
// Password changed on a post in a public status.
if ( $is_public && $post->post_password !== $post_before->post_password ) {
wp_cache_set_last_changed( 'postpass' );
return;
}
// Post with a password moved into a public status.
if ( ! $was_public && $is_public && '' !== $post->post_password ) {
wp_cache_set_last_changed( 'postpass' );
return;
}
// Post with a password moved out of a public status.
if ( $was_public && ! $is_public && '' !== $post_before->post_password ) {
wp_cache_set_last_changed( 'postpass' );
}
}
add_action( 'wp_after_insert_post', 'postpass_maybe_invalidate_on_save', 10, 4 );
/**
* Invalidate the postpass cache group when a public password-protected post is permanently deleted.
*
* @param int $post_id Post ID.
* @param WP_Post $post Post object.
*/
function postpass_maybe_invalidate_on_delete( int $post_id, WP_Post $post ): void {
if ( '' !== $post->post_password && in_array( $post->post_status, postpass_public_statuses(), true ) ) {
wp_cache_set_last_changed( 'postpass' );
}
}
add_action( 'before_delete_post', 'postpass_maybe_invalidate_on_delete', 10, 2 );
/**
* Block REST API access to unauthorized users.
*
* @param WP_Error|null|bool $maybe_error The previous authentication check result.
*
* @return WP_Error|null|bool The authentication check result, or a new error if the user is not authorized.
*/
function limit_rest_api( WP_Error|null|bool $maybe_error ): WP_Error|null|bool {
/**
* If a previous authentication check was applied, pass that result along without modification.
*
* @see https://developer.wordpress.org/rest-api/frequently-asked-questions/#can-i-disable-the-rest-api
*/
if ( true === $maybe_error || is_wp_error( $maybe_error ) ) {
return $maybe_error;
}
// Get post password cookie.
$cookie_key = 'wp-postpass_' . COOKIEHASH;
$cookie_hash = isset( $_COOKIE[ $cookie_key ] ) ? wp_unslash( $_COOKIE[ $cookie_key ] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___COOKIE
// Validate cookie against all post passwords in database.
if ( ! empty( $cookie_hash ) && validate_postpass_cookie_against_all_posts( $cookie_hash ) ) {
/**
* Valid cookie - allow access.
* The permission callbacks will still validate the cookie against
* the SPECIFIC post being requested for defense in depth.
*/
return $maybe_error;
}
// Deny access to the REST API for unauthorized users.
if ( ! is_user_logged_in() ) {
return new \WP_Error(
'rest_auth_required',
'Not authorized',
array( 'status' => 401 )
);
}
return $maybe_error;
}
add_filter( 'rest_authentication_errors', 'limit_rest_api' );
/**
* Filter the user's capabilities to allow access to password-protected posts.
*
* @param array $all_caps An array of all the user's capabilities.
* @param array $caps Required primitive capabilities for the requested capability.
* @param array $args Adds the context to the capability check.
* @param WP_User $user The user object.
*
* @return array The filtered array of all the user's capabilities.
*/
function user_read_capability_filter( array $all_caps, array $caps, array $args, WP_User $user ): array {
if ( isset( $args[0] ) && 'read_post' === $args[0] && isset( $args[2] ) ) {
$post = get_post( $args[2] );
if ( ! $post ) {
return $all_caps;
}
$can_read = user_can_read_post( $post->ID );
if ( $can_read && ! isset( $all_caps['read'] ) ) {
$all_caps['read'] = true;
}
}
return $all_caps;
}
add_filter( 'user_has_cap', 'user_read_capability_filter', 10, 4 );
/**
* Filter meta capabilities to allow certain anonymous users access to posts.
*
* @param array $caps Primitive caps WP will check (returned value).
* @param string $cap The meta cap being checked (e.g., 'read_post').
* @param int $user_id The user ID (0 for anonymous).
* @param array $args Additional args; for 'read_post', $args[0] is post ID.
*
* @return array The filtered array of required primitive capabilities.
*/
function anonymous_user_meta_cap_filter( array $caps, string $cap, int $user_id, array $args ): array {
if ( 'read_post' !== $cap || empty( $args[0] ) ) {
return $caps;
}
$post_id = (int) $args[0];
$post = get_post( $post_id );
if ( ! $post ) {
return $caps;
}
if ( user_can_read_post( $post_id ) ) {
/**
* Disallow happens before allow, so we return an empty array to indicate no restrictions.
*
* No primitive capabilities are required to read the post.
*/
return array();
}
return $caps;
}
add_filter( 'map_meta_cap', 'anonymous_user_meta_cap_filter', 10, 4 );
/**
* Check if the user can read a post by checking the post's password.
*
* @param int $post_id The ID of the post.
*
* @return bool Whether the user can read the post.
*/
function user_can_read_post( int $post_id ): bool {
$post = get_post( $post_id );
if ( ! $post ) {
return false;
}
// The correct way to auth here is with a header, but this will work temporarily. Change it for better security.
if ( ! is_user_logged_in() && isset( $_COOKIE[ 'wordpress_logged_in_' . COOKIEHASH ] ) ) {
$login_cookie_value = $_COOKIE[ 'wordpress_logged_in_' . COOKIEHASH ]; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___COOKIE
$user_id = wp_validate_auth_cookie( $login_cookie_value, 'logged_in' );
if ( $user_id ) {
wp_set_current_user( $user_id );
}
}
if ( empty( $post->post_password ) || is_user_logged_in() ) {
remove_filter( 'user_has_cap', 'user_read_capability_filter', 10, 4 );
$current_user_can_read = current_user_can( 'read', $post_id );
add_filter( 'user_has_cap', 'user_read_capability_filter', 10, 4 );
return $current_user_can_read;
}
/**
* At this point, we know the post is password protected and the user is not logged in.
*/
$cookie_key = 'wp-postpass_' . COOKIEHASH;
$cookie_hash = isset( $_COOKIE[ $cookie_key ] ) ? wp_unslash( $_COOKIE[ $cookie_key ] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___COOKIE
// Validate password using PasswordHash.
if ( ! class_exists( '\PasswordHash' ) ) {
require_once ABSPATH . WPINC . '/class-phpass.php';
}
$hasher = new \PasswordHash( 8, true );
$password_matches = $hasher->CheckPassword( $post->post_password, $cookie_hash );
return $password_matches;
}
Code language: PHP (php)

Leave a Reply