November 5, 2026
The Password Was in the JSON: A WordPress REST API Access-Control Bug in Secure Custom Fields
SCF 6.9.6 checked whether a referenced post was readable — but not whether its password-protected content was. In affected configurations…

By Burrito
7 min read
SCF 6.9.6 checked whether a referenced post was readable — but not whether its password-protected content was. In affected configurations, the result was an unauthenticated leak of post content and plaintext passwords. Fixed in 6.9.7.
By Burrito
TL;DR: SCF 6.9.6 used
check_read_permission()to decide whether to expand a referenced post in a REST-exposed field group. That check returns true for any published post, including password-protected ones. SCF then serialized the fullWP_Postobject — includingpost_password— into REST responses. SCF 6.9.7 added the missingpost_password_required()check. Update to 6.9.8 or later.
WordPress's REST API is often the public face of a site. Headless front ends, mobile apps, and integrations consume /wp-json/wp/v2/... directly. When a plugin extends those responses with custom fields, it inherits a simple responsibility: do not return data the caller is not allowed to see.
Secure Custom Fields (SCF) is the WordPress.org fork of Advanced Custom Fields and powers custom fields on more than 100,000 sites. In version 6.9.6, a reference-expansion feature missed part of that responsibility. A public post could reference a password-protected post through a Post Object field in a REST-exposed field group, and an unauthenticated REST request would receive the protected post's full WP_Post object — including its content and its plaintext post_password.
The interesting part is not that a permission check was missing. SCF had one. The interesting part is that it checked the wrong boundary.
How SCF exposes custom fields over REST
SCF lets editors attach fields to posts, terms, users, and options pages. A Post Object field stores the referenced post's ID. On the front end, get_field() can return a full WP_Post object; in REST, the field can be returned in two shapes:
- light (the default): numeric IDs;
- standard (
?acf_format=standard): the formatted value, which for a Post Object field with the defaultPost Objectreturn format is the full object.
Every REST response also includes a _source entry with metadata about the field, including formatted_value. That detail matters: the _source value is formatted in standard mode even when the main field value is in light mode.
For the issue to be reachable, the field group must have Show in REST API enabled. That setting is off by default, but it is commonly enabled for headless WordPress, mobile apps, and API-driven integrations. The source post must be public, and it must reference a published password-protected post.
The vulnerable path
In 6.9.6, SCF added acf_rest_reference_is_exposable() to decide whether a referenced object should be expanded. It was a security hardening change intended to stop references to posts the caller could not read. The function resolves the target post, checks that its post type is REST-exposed, and asks the post type's REST controller whether the caller can read it:
$controller = new $controller_class( $post->post_type );
if ( method_exists( $controller, 'check_read_permission' ) ) {
$exposable = (bool) $controller->check_read_permission( $post );
}$controller = new $controller_class( $post->post_type );
if ( method_exists( $controller, 'check_read_permission' ) ) {
$exposable = (bool) $controller->check_read_permission( $post );
}On the surface, this looks right. check_read_permission() is the same method WordPress core uses when you request a post directly. But it answers a narrower question than the one SCF needed.
Here is the core implementation, simplified:
public function check_read_permission( $post ) {
$post_type = get_post_type_object( $post->post_type );
if ( ! $this->check_is_post_type_allowed( $post_type ) ) {
return false;
}
if ( 'publish' === $post->post_status || current_user_can( 'read_post', $post->ID ) ) {
return true;
}
// ...
}public function check_read_permission( $post ) {
$post_type = get_post_type_object( $post->post_type );
if ( ! $this->check_is_post_type_allowed( $post_type ) ) {
return false;
}
if ( 'publish' === $post->post_status || current_user_can( 'read_post', $post->ID ) ) {
return true;
}
// ...
}A published post is readable. Password protection is not part of this method.
WordPress enforces the password later, while preparing the response:
$data['content']['rendered'] = post_password_required( $post )
? ''
: apply_filters( 'the_content', $post->post_content );$data['content']['rendered'] = post_password_required( $post )
? ''
: apply_filters( 'the_content', $post->post_content );and:
public function can_access_password_content( $post, $request ) {
if ( empty( $post->post_password ) ) {
return false;
}
if ( 'edit' === $request['context'] && current_user_can( 'edit_post', $post->ID ) ) {
return true;
}
if ( empty( $request['password'] ) ) {
return false;
}
return hash_equals( $post->post_password, $request['password'] );
}public function can_access_password_content( $post, $request ) {
if ( empty( $post->post_password ) ) {
return false;
}
if ( 'edit' === $request['context'] && current_user_can( 'edit_post', $post->ID ) ) {
return true;
}
if ( empty( $request['password'] ) ) {
return false;
}
return hash_equals( $post->post_password, $request['password'] );
}That second stage is what blanks content.rendered, sets content.protected to true, and keeps the password out of the response. SCF's reference formatter never went through it. It took the raw WP_Post object and serialized it.
Reproducing the leak
Lab setup: WordPress 7.1.3, SCF 6.9.6, and PHP 8.2 on a disposable WordPress instance. No third-party data was accessed.
- Create a published post with a password and a unique body. Call it the protected target (ID 11).
- Create a public post with no password. Call it the public source (ID 12).
- Create a field group with a Post Object field: — Return format: Post Object — Location: Post Type = Post — Show in REST API: enabled
- Assign the field group to the Post post type, edit the public source, and select the protected target.
- Request the public source as an unauthenticated visitor.
Two requests are enough to see the leak:
GET /wp-json/wp/v2/posts?search=SCF%20public%20source
GET /wp-json/wp/v2/posts/12?acf_format=standardGET /wp-json/wp/v2/posts?search=SCF%20public%20source
GET /wp-json/wp/v2/posts/12?acf_format=standardA sanitized excerpt from the first response:
"related_post_demo_source": {
"label": "Related post",
"type": "post_object",
"formatted_value": {
"ID": 11,
"post_title": "SCF protected target",
"post_status": "publish",
"post_password": "[REDACTED]",
"post_content": "[REDACTED]"
}
}"related_post_demo_source": {
"label": "Related post",
"type": "post_object",
"formatted_value": {
"ID": 11,
"post_title": "SCF protected target",
"post_status": "publish",
"post_password": "[REDACTED]",
"post_content": "[REDACTED]"
}
}The acf_format=standard response contains the same object under related_post_demo.
The control request tells a different story:
GET /wp-json/wp/v2/posts/11
{
"id": 11,
"content": {
"rendered": "",
"protected": true
},
"excerpt": {
"rendered": "",
"protected": true
}
}GET /wp-json/wp/v2/posts/11
{
"id": 11,
"content": {
"rendered": "",
"protected": true
},
"excerpt": {
"rendered": "",
"protected": true
}
}No post_password field. The content is empty. Core is doing exactly what it should. SCF is not.
The root cause
The bug is a mismatch between the permission check and the shape of the data being returned. check_read_permission() answers: "Can this caller read this post at all?" For a published post, the answer is yes, even when the post is password-protected. The password check is a second, content-level boundary:
- Can the caller see the protected body?
- Can the caller see the password?
SCF asked the first question and then returned data that required the second.
Because the formatter returns the raw WP_Post, the leak includes every public property of the object. post_password is stored in plaintext in the database and is part of WP_Post. Even if the response had only included the password and not the body, the impact would be significant: the attacker could use the password to view the protected post normally.
The fix
SCF 6.9.7, released on October 21, 2026, adds the missing boundary:
$controller = $post_type_obj->get_rest_controller();
if ( $controller && method_exists( $controller, 'check_read_permission' ) ) {
$exposable = (bool) $controller->check_read_permission( $post );
}
if ( $exposable && post_password_required( $post ) && ! current_user_can( 'edit_post', $post->ID ) ) {
$exposable = false;
}$controller = $post_type_obj->get_rest_controller();
if ( $controller && method_exists( $controller, 'check_read_permission' ) ) {
$exposable = (bool) $controller->check_read_permission( $post );
}
if ( $exposable && post_password_required( $post ) && ! current_user_can( 'edit_post', $post->ID ) ) {
$exposable = false;
}Two details matter:
post_password_required( $post )respects the normal password flow. A visitor who has entered the password can still see the content; everyone else cannot.current_user_can( 'edit_post', $post->ID )preserves the editor experience. Users who can edit the post are not blocked by a password they could remove anyway.
The check sits in acf_rest_reference_is_exposable(), which is shared by the standard-format sanitizer and the _source path, so the fix covers both. The 6.9.7 changelog explicitly names Post Object and Relationship fields, and the shared helper applies the same boundary to both. The function also switched to get_rest_controller(), which avoids instantiating a controller manually and keeps the behavior aligned with core.
SCF 6.9.8, released on November 3, 2026, includes additional hardening. The current recommendation is to run 6.9.8 or later.
Impact and preconditions
Impact: confidentiality. An unauthenticated visitor can read the body of a password-protected post and its plaintext password, then use the password through the normal WordPress UI. Public posts are enumerable through the REST API, so no privileged access is required. The data could include PII, internal documents, or business content.
Preconditions: a REST-exposed field group containing a Post Object (or similar reference) field, with the default Post Object return format, and a public post that references a published password-protected post. The show_in_rest setting defaults to false. This is not a default-install vulnerability, but it is a realistic configuration for API-driven sites. Relationship fields that expand objects can follow the same pattern.
Affected version tested: SCF 6.9.6. Earlier 6.9.x releases should be treated as affected until verified; the safest course is to update to 6.9.8 or later.
No integrity or availability impact was observed.
Remediation and detection
- Update SCF to 6.9.8 or later.
- If you cannot update immediately:
- If you do not need them over REST, disable Show in REST API on field groups that contain Post Object, Relationship, Page Link, Image, Gallery, or File fields.
- Avoid referencing password-protected posts from public posts.
- Add a temporary filter:
php add_filter( 'acf/rest/expose_reference_target', function( $exposable,
$target_id ) { $target = get_post( $target_id );
if ( $target && post_password_required( $target ) && ! current_user_can( 'edit_post', $target->ID ) ) {
return false;
}
return $exposable;
}, 10, 2 ); ```php add_filter( 'acf/rest/expose_reference_target', function( $exposable,
$target_id ) { $target = get_post( $target_id );
if ( $target && post_password_required( $target ) && ! current_user_can( 'edit_post', $target->ID ) ) {
return false;
}
return $exposable;
}, 10, 2 ); ```- Audit REST responses for
post_password. Do not log full responses in production; if you must, redact the field.
4. Review access logs for unusual acf_format=standard or _source requests against public posts. A WAF rule can help, but it may break legitimate clients; fixing the plugin is better.
5. Inventory exposed field groups. Export your field groups and look for "show_in_rest": true on groups that contain post_object or relationship fields. Then check whether any public post references a password-protected post.
Lessons for plugin developers
- A permission check is only correct if it covers the data you are about to return.
check_read_permission()is a post-level check; password protection is a content-level check. - Returning a raw data object is a risk.
WP_Postcontains fields the REST layer normally filters. If you serialize it directly, you bypass those filters. - Share the check, not just the code path. The fix belongs in the shared
acf_rest_reference_is_exposable()function so every reference format inherits it. - Configuration-dependent bugs still matter.
show_in_restis off by default, but the sites that turn it on are exactly the ones exposing data to the public API.
Disclosure note
This issue was independently reproduced in a lab and reported upstream. It was closed as a duplicate of an earlier report covering the same reference-expansion problem; credit for the original finding belongs to the earlier reporter. The fix shipped in SCF 6.9.7 on October 21, 2026. No third-party data was accessed, and this writeup does not contain any private report contents.
References
- Secure Custom Fields changelog
- WordPress REST API handbook
- WP_REST_Posts_Controller
- post_password_required()
- WP_REST_Posts_Controller::can_access_password_content()
- Secure Custom Fields source on GitHub
If you maintain a WordPress plugin that exposes references over REST, test the password-protected case. It is easy to miss.