wp-password-protected-content
Implements and audits WordPress built-in password-protected posts, pages, and custom post types. Covers `post_password`, `post_password_required()`, `get_the_password_form()`, the `wp-login.php?action=postpass` handler, `wp-postpass_` cookie semantics, REST `password` requests, c
Install
npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/wordpress/wp-password-protected-content
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart
git clone https://github.com/Lonsdale201/wp-agent-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lonsdale201/wp-agent-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
WordPress password-protected content
Implement or review WordPress's built-in shared-password gate without treating it as user authentication or encrypted storage. Preserve the core form/cookie contract, guard every custom output surface, and prevent caches from publishing an unlocked representation.
Read references/runtime-and-extension-contracts.md when working on headless/REST access, replacing the form, changing bypass policy, or auditing exactly which core surfaces are and are not protected.
When to use this skill
- Code uses
post_passwordorhas_passwordto control public post, page, product, course, or custom-post-type visibility. - A template, block, shortcode, widget, email, feed, export, REST route, or GraphQL resolver displays data belonging to a protected post.
- Code calls
post_password_required(),get_the_password_form(),get_the_content(), or filtersthe_password_form. - A theme changes the protected title, invalid-password message, form markup, or WordPress 7.1 block-theme styling.
- A headless or mobile client must read a protected post or its comments.
- A cache/CDN serves a different response before and after password entry.
- The requested feature needs one shared secret, and you must decide whether core post-password protection is strong enough.
Do not trigger merely for user-password/login code or because an internal data store reuses the column, such as legacy Action Scheduler claim IDs or legacy WooCommerce order keys. Verify the field's semantic purpose before auditing it.
Choose the correct access primitive
Use the built-in post password only when all intended readers may share one secret and disclosure of the post title/permalink is acceptable.
| Requirement | Appropriate mechanism |
|---|---|
| One shared secret gates ordinary post content | Core post password can fit. |
| Per-user grant/revocation, audit, expiry, ownership, subscription, or role | Authenticated users plus capability/entitlement policy. |
| Hide the post's existence from anonymous visitors | Private/custom status plus authorization, not only post_password. |
| Protect original media URLs or downloadable files | Authorized delivery outside public uploads or signed/controlled downloads. |
| High-value confidential data | Purpose-built authentication and authorization; do not rely on a shared post password. |
The password is stored as plaintext in wp_posts.post_password; the post body
is not encrypted. The browser cookie stores a salted PHPass hash, but acts as a
site-scoped proof for whichever post has the matching plaintext password. Core
has no per-reader identity, revocation list, attempt counter, or audit trail.
Understand the core contract
- A published post retains a non-empty
post_passwordvalue. - Locked content renders
get_the_password_form()rather than the body. - The form posts
post_passwordandredirect_totowp-login.php?action=postpass. - Core writes one
wp-postpass_plusCOOKIEHASHcookie for the site and redirects safely to the post. post_password_required( $post )verifies that cookie hash against the post's current plaintext password.
The cookie is not keyed by post ID. Posts sharing a password unlock together; entering a different password replaces the cookie, so only one distinct post password works in that browser at a time. Logged-in users do not automatically bypass the frontend check.
Guard all custom output
Check the exact owning post before reading or rendering custom data:
$post = get_post( $post_id );
if ( ! $post ) {
return '';
}
if ( post_password_required( $post ) ) {
// Let the owning post template render the core form once.
return '';
}
return esc_html( (string) get_post_meta( $post->ID, '_acme_summary', true ) );
Use the same boundary for custom fields, blocks, shortcodes, related records,
downloads, JSON, email, exports, and secondary queries. Never infer access from
cookie presence alone; post_password_required() validates it.
Do not assume these operations enforce the gate:
get_post(),get_post_field(), direct post content properties, and$wpdb;get_post_meta()or custom-table lookups;- directly applying
the_contentto a raw content string; get_the_post_thumbnail(), attachment metadata, or a public uploads URL;- a custom REST/GraphQL endpoint or search indexer.
Core template functions protect the ordinary content/excerpt path and several comment, feed, block-binding, and block surfaces. That is not a general data- access policy for plugin code.
Extend the form without breaking it
Prefer styling or a surgical WP_HTML_Tag_Processor change over rebuilding the
entire form. This retains the action, redirect, unique label/input ID, error
announcement, translations, and WordPress 7.1 block-theme button classes:
add_filter(
'the_password_form',
static function ( string $html, WP_Post $post, string $invalid_password ): string {
$processor = new WP_HTML_Tag_Processor( $html );
while ( $processor->next_tag( 'input' ) ) {
if ( 'post_password' === $processor->get_attribute( 'name' ) ) {
$processor->add_class( 'acme-post-password' );
break;
}
}
return $processor->get_updated_html();
},
10,
3
);
If full replacement is unavoidable, preserve every contract listed in the reference and test classic plus block themes. Filter output is a trusted plugin/theme surface: escape translated text and attributes deliberately.
WordPress 7.1 wraps the submit control with .wp-block-button and applies
.wp-block-button__link plus the current button element class for block themes;
it also enqueues the registered wp-block-button style. A legacy full-form
replacement silently loses that improvement.
Add bypasses as authorization policy
Scope post_password_required narrowly. For an editorial frontend preview, an
object-level edit capability is a defensible bypass:
add_filter(
'post_password_required',
static function ( bool $required, WP_Post $post ): bool {
if ( $required && current_user_can( 'edit_post', $post->ID ) ) {
return false;
}
return $required;
},
10,
2
);
Do not substitute read_post as a membership check: for an ordinarily
published post it is commonly true for logged-in readers and does not express
the intended entitlement. Prefer a dedicated capability or an explicit
object-level entitlement service. Remember that this filter influences every
caller, including comments, blocks, feeds, and REST preparation.
Handle REST and headless clients deliberately
Prefer the core posts endpoint for core post fields. A single-item request may
supply the plaintext password; without it, content.rendered and
excerpt.rendered are empty and their protected flags are true. A wrong
supplied password returns rest_post_incorrect_password with HTTP 403. An
authenticated editor using context=edit can access content when allowed to
edit that post.
Passwords in query strings can enter browser history, proxy/CDN logs, analytics, traces, and error reports. Require HTTPS, redact the parameter, do not persist it in client state, and never shared-cache a successful response. For higher-value or per-user content, use authenticated authorization rather than extending the query-password design.
Custom endpoints must independently protect every additional field. A public
permission_callback followed by an unchecked meta/custom-table read bypasses
the post password even when the core posts response is correct.
Isolate caches
Core sends no-cache headers for singular posts with a non-empty
post_password, whether currently locked or unlocked. Preserve them. A page
cache or CDN that serves before WordPress runs must also bypass protected URLs
or requests carrying a wp-postpass_ cookie; never let unlocked HTML populate
an anonymous cache key.
Apply the same rule to REST responses, fragments, edge rendering, service workers, and headless caches. Purging after a password change does not fix a design that mixes locked and unlocked variants.
Audit and test
Test observable behavior with a temporary protected post:
- No cookie, malformed cookie, correct cookie, wrong cookie, and expired cookie.
- Two posts sharing a password and two posts using different passwords.
- Anonymous, subscriber, editor without cookie, and explicitly authorized bypass behavior.
- Content, excerpt, title, comments, featured image/media, custom meta, dynamic blocks, related records, feeds, email, exports, and custom APIs.
- REST with absent, correct, and wrong password;
viewversus permittededitcontext; comment endpoints where applicable. - Classic and block themes, invalid-password announcement, keyboard/label behavior, duplicate forms, and WordPress 7.1 button classes.
- Origin/page cache, CDN, query logs, browser history, password changes, and a direct public media URL.
Distinguish a content leak from a UI mismatch. Report file/line, post/output surface, request identity and cookie/password state, cache layer, exposed data, and the smallest policy-preserving fix.
Severity guide
- HIGH: locked body, custom data, comments, export, or unlocked cached response is available without the password/authorized entitlement; a file claimed to be private remains publicly downloadable.
- MEDIUM: brute-force exposure without required controls, broad filter bypass, password-bearing URL leakage, or cache behavior that can mix variants under realistic infrastructure conditions.
- LOW: inaccessible form, lost invalid-password feedback, missing 7.1 theme styling, overly long cookie lifetime, or misleading protected-title output.
- INFO: the built-in shared-secret model cannot satisfy stated per-user, confidentiality, audit, or private-file requirements and needs redesign.
Do not label the core form's missing nonce as CSRF by itself. The handler changes only the visitor's post-password cookie and does not grant server-side user privileges. If a customization adds account, entitlement, or persistent state changes, protect those mutations separately.
Critical rules
- Treat post-password protection as presentation gating, not encryption or user authentication.
- Check the exact post with
post_password_required()before every custom output surface. - Do not reveal protected data through public media URLs, metadata, related tables, custom blocks, APIs, feeds, emails, or caches.
- Never test only cookie presence; verify through core or an equivalent explicit password check at an API boundary.
- Never shared-cache unlocked HTML or a successful password-bearing response.
- Preserve the form action, field names, safe redirect, accessibility, and 7.1 block-theme behavior when customizing markup.
- Do not log plaintext post passwords or return
post_passwordin public API responses. - Prefer authenticated object-level authorization when requirements exceed one shared site-scoped secret.
Cross-references
- Run
wp-rest-apifor a custom protected REST resource or headless route. - Run
wp-security-auditfor capability, output, custom download, and endpoint review around the gate.
What this skill does NOT cover
- WordPress login passwords, reset flows, Application Passwords, or Basic Auth.
- Membership, paywall, DRM, document-room, or per-user entitlement systems.
- Making public uploads private merely because their parent post is protected.
- Whole-site password protection or HTTP server authentication.
References
- Detailed runtime, output, hook, REST, and extension contracts: references/runtime-and-extension-contracts.md
- Official core references:
post_password_required(),get_the_password_form(), and the posts REST endpoint. - WordPress 7.1 protected-form accessibility note: https://make.wordpress.org/core/2026/08/13/accessibility-improvements-in-wordpress-7-1/
- Verified WordPress 7.1 source paths:
wp-includes/post-template.php,wp-login.php,wp-includes/class-wp.php,wp-includes/class-wp-query.php,wp-includes/comment.phpwp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php,wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.phpwp-includes/block-bindings/post-meta.php,wp-includes/block-bindings/post-data.php
Files (wp-agent-skills)
-
agents
-
openai.yaml 243 B
interface: display_name: "WP Password-Protected Content" short_description: "Extend built-in post password protection" default_prompt: "Use $wp-password-protected-content to implement or audit WordPress post password protection safely."
-
-
references
-
runtime-and-extension-contracts.md 12.7 KB
# Runtime and extension contracts Read this reference when replacing the form, implementing headless access, adding bypass policy, or auditing a suspected leak. The contracts below are verified against WordPress 7.1 source and runtime behavior. ## Runtime sequence and data model ### Storage and creation - `wp_posts.post_password` is `varchar(255)` and stores the post password in plaintext. It is unrelated to a user's hashed `user_pass` value. - Some legacy systems reuse this generic posts-table column for internal data, such as an Action Scheduler claim ID or an order key. Apply this skill only when the value participates in WordPress content visibility. - `wp_insert_post()` and `wp_update_post()` accept `post_password`. They do not authorize the caller; the calling handler must already be authorized. - Core admin handling removes a submitted post password when the user lacks the post type's `publish_posts` capability. - `wp_insert_post()` clears `post_password` when it stores a private post. Private visibility and shared-password visibility are separate models. - Clearing `post_password` removes the gate. Changing it invalidates the old cookie for that post because the stored hash no longer verifies. The editor UI and database field allow up to 255 characters. Do not promise a larger value merely by changing a frontend input's attributes. ### Submission and cookie The core form posts to: ```text wp-login.php?action=postpass ``` Its relevant contract is: ```text method: POST field: post_password redirect: redirect_to, normally the exact post permalink cookie name: wp-postpass_{COOKIEHASH} default TTL: 10 days ``` The WordPress 7.1 handler: 1. obtains `redirect_to` from POST or the referrer; 2. hashes the unslashed submitted value with portable PHPass; 3. writes the site-wide cookie; 4. derives the Secure flag from the redirect URL's scheme; and 5. performs a safe redirect. The handler does not first resolve a post or verify its password. A wrong value still becomes the new cookie; the redirected post detects that it does not match. Core's cookie contains a reusable hash rather than the plaintext, but WordPress 7.1 does not set it HttpOnly or attach an explicit SameSite attribute. Treat XSS as capable of stealing this proof. It is not account authentication or a high-assurance secret store. ### Verification `post_password_required( $post )` behaves as follows: 1. no stored post password means access is not required; 2. a missing cookie means the password is required; 3. a cookie that does not begin with WordPress's portable `$P$B` prefix is rejected; 4. `PasswordHash( 8, true )->CheckPassword()` compares the post's plaintext password to the cookie hash; and 5. `post_password_required` filters the resulting boolean with the `WP_Post`. Consequences: - one cookie is shared across the site, not keyed by post ID; - equal post passwords unlock each other; - submitting another distinct password replaces the first proof; - logged-in users, administrators included, do not automatically bypass the frontend gate; and - cookie presence alone never proves access. ## Built-in output matrix | Surface | WordPress 7.1 default behavior | Extension risk | |---|---|---| | Singular title | Remains visible and uses `protected_title_format` while `post_password` is non-empty, even after unlocking. | A custom title API can omit the disclosure marker or expose confidential title text. | | `get_the_content()` | Returns the password form while locked. | Reading `$post->post_content`, `get_post_field()`, or filtering a raw string bypasses the gate. | | `get_the_excerpt()` | Returns the protected-post message while locked. | Raw `post_excerpt` remains directly readable. | | Post classes | Uses `post-password-required` while locked and `post-password-protected` after unlock. | Do not use CSS class state as authorization. | | Core post-content rendering | Respects the gate. | Custom block renderers and secondary data must guard their owning post. | | Core post-data/post-meta block bindings | Return their protected fallback or no value while locked. | Other dynamic sources, custom bindings, and direct meta reads are not automatically covered. | | Comments | Core display and submission flows block access while the parent post is locked. | Custom comment queries and public APIs can bypass the parent check. | | Feeds and enclosures | Core feed paths suppress protected content/enclosures. | Custom feeds, podcast XML, and webhooks must implement the same policy. | | Logged-out search | `WP_Query` search excludes password-protected posts. Logged-in search does not apply that exclusion. | Custom search/index services may reveal title, excerpt, or fields. | | Featured images and attachments | A template helper or public uploads URL is not a universal private-file boundary. | The original file may remain directly downloadable. | | Meta and custom tables | Direct reads return the stored values. | Always guard the parent post before output. | | `get_post()`, post properties, `$wpdb` | Return raw stored data. | These are data APIs, not authorization APIs. | | Core posts REST response | Public `view` requests keep rendered content/excerpt empty until the password is accepted; their `protected` flags remain true. | Extra registered fields and custom routes need their own guard. | Also review menus, cards, related-content widgets, schema/OpenGraph metadata, sitemaps, emails, exports, webhooks, analytics payloads, search documents, and AI/vector indexes. A protected primary template does not secure these secondary representations. ## Hook contracts ### `post_password_required` ```php apply_filters( 'post_password_required', bool $required, WP_Post $post ); ``` Use this only for a deliberate authorization rule. Scope it to the exact post, post type, request context, and object-level capability or entitlement. A broad `return false` changes every caller, including REST preparation, comments, blocks, and feeds. ### `the_password_form` ```php apply_filters( 'the_password_form', string $output, WP_Post $post, string $invalid_password ); ``` A full replacement must preserve: - POST to `site_url( 'wp-login.php?action=postpass', 'login_post' )`; - hidden `redirect_to` with the exact permalink; - input name `post_password`; - a unique label/input ID such as `pwbox-{post ID}`; - accessible label and submit controls; - visible and announced invalid-password feedback; - translated and escaped text/attributes; and - the WordPress 7.1 block-theme button wrapper and classes where applicable. Multiple protected components can otherwise emit duplicate IDs or multiple forms. Prefer one owning form boundary or preserve core's post-specific ID. ### `the_password_form_incorrect_password` ```php apply_filters( 'the_password_form_incorrect_password', string $incorrect_password_text, WP_Post $post ); ``` WordPress considers the password invalid when the raw referrer equals the post permalink and a postpass cookie exists. The returned string is inserted into trusted form markup, so escape custom text yourself: ```php add_filter( 'the_password_form_incorrect_password', static function ( string $text, WP_Post $post ): string { return esc_html__( 'That password did not match. Try again.', 'acme' ); }, 10, 2 ); ``` ### `post_password_expires` ```php apply_filters( 'post_password_expires', time() + 10 * DAY_IN_SECONDS ); ``` The value is an absolute Unix timestamp, not a duration. Return `0` for a session cookie. The filter receives no post ID, so a post-specific TTL needs a separate flow or carefully controlled request context. ```php add_filter( 'post_password_expires', static function (): int { return time() + HOUR_IN_SECONDS; } ); ``` ### `protected_title_format` The format normally contains one `%s` placeholder for the original title: ```php add_filter( 'protected_title_format', static function (): string { return __( 'Restricted: %s', 'acme' ); } ); ``` This filter changes presentation only. It neither grants access nor hides the title. ### `login_form_postpass` This action fires in `wp-login.php` before core handles the submission. It can support telemetry or global abuse controls, but it is not post-specific because core has not resolved a target post. Never log the submitted password. If a custom flow mutates account, entitlement, or persistent state, apply its own nonce and authorization controls. The core post-password form has no nonce. That alone is not a privilege- escalation CSRF: by default, submission only replaces the current visitor's postpass cookie. Treat added state-changing behavior separately. ## REST and headless behavior For a core post at `/wp-json/wp/v2/posts/{id}`: | Request state | Expected result | |---|---| | Anonymous `context=view`, no password/cookie | HTTP 200; rendered content/excerpt empty, `protected: true`. | | Anonymous `context=view`, wrong non-empty `password` | `rest_post_incorrect_password`, HTTP 403. | | Anonymous `context=view`, correct plaintext `password` | HTTP 200 with rendered protected fields. | | Same-origin client with a matching postpass cookie | The ordinary `post_password_required()` path can satisfy the gate without repeating the query parameter. | | Authenticated user with permitted `context=edit` | Access is based on the post's edit capability; the password field is exposed only in edit context. | The `password` parameter is a read-time proof, not the cookie's PHPass hash. Sending the hash as the REST password is incorrect. Query parameters are liable to enter browser history, proxy/access logs, CDN keys, analytics, traces, and error reports. Require HTTPS, redact `password`, avoid persisting it in application state, and prevent shared caching of a successful response. For an SPA, consider a same-origin server-side exchange; for a mobile or high-value system, prefer authenticated object authorization. Core comment routes accept the parent post password where applicable. Constrain comment collection requests to the exact parent post, and retest both listing and creation. Do not assume an unrelated custom comment endpoint inherits that behavior. Custom endpoint anti-patterns include: - a public `permission_callback` followed by unchecked post/meta/table reads; - accepting a password but comparing it with loose equality; - treating a postpass cookie's existence as proof; - returning `post_password` to help a client compare locally; - caching the unlocked response under the same URL/key as the locked response; - exposing an attachment URL that bypasses the endpoint entirely; and - using `read_post` as a paid-access or membership entitlement. When a custom endpoint is justified, separate route permission from representation gating: authorize the caller, resolve the exact owning object, verify its access policy, and only then prepare fields. ## Queries and listings `WP_Query` supports selection filters, not password authentication: ```php new WP_Query( array( 'has_password' => true ) ); new WP_Query( array( 'has_password' => false ) ); new WP_Query( array( 'post_password' => 'shared-value' ) ); ``` `has_password` selects rows with or without a stored password. `post_password` selects rows with an exact stored plaintext value. Neither proves that the current request may reveal those rows' protected fields. For every listing, decide independently whether locked posts should be: - omitted entirely; - shown with title/permalink but no protected excerpt/data; or - shown only after the exact post passes its access check. Do not rely on core's anonymous search exclusion for archives, custom SQL, REST collections, third-party indexing, or logged-in users. ## Cache contract For a singular post with non-empty `post_password`, core's `WP::send_headers()` sends no-cache headers whether the current cookie is locked or unlocked. This helps only when the request reaches WordPress. | Variant | Safe cache treatment | |---|---| | No cookie, locked HTML | Do not mix with unlocked HTML; obey no-cache. | | Correct cookie, unlocked HTML | Private/no-store or equivalent; never shared-cache. | | Wrong or malformed cookie | Locked response; do not create a reusable unlocked variant. | | Password changed, old cookie | Verification fails and the post locks again. Purge any stale external representation. | | Expired cookie | Locked response. | | Correct REST `password` | Do not shared-cache; redact query/log data. | | Direct public media URL | Assume public until a separate delivery control proves otherwise. | At an edge cache, bypass protected post URLs and requests carrying a cookie whose name begins `wp-postpass_`. Apply equivalent isolation to fragments, service workers, static generation, mobile caches, preview systems, and search indexes. Cache purging is not a substitute for preventing cross-variant reuse.
-
-
SKILL.md 13.6 KB
--- name: wp-password-protected-content description: >- Implements and audits WordPress built-in password-protected posts, pages, and custom post types. Covers `post_password`, `post_password_required()`, `get_the_password_form()`, the `wp-login.php?action=postpass` handler, `wp-postpass_` cookie semantics, REST `password` requests, cache isolation, protected comments/feeds, and guarding custom meta, blocks, media, and API output. Use when extending the password form, changing cookie lifetime or protected titles, adding editor/role bypasses, building a headless reader, or reviewing leaks where content visibility relies on a post password. Do not use for user login, Application Password, membership, private-file auth, or an internal data store that merely reuses the `post_password` column. license: GPLv2-or-later metadata: wp-skills-author: "Soczó Kristóf" wp-skills-contact: "mailto:lonsdale201@hotmail.com" wp-skills-plugin: "wordpress" wp-skills-plugin-version-tested: "7.1" wp-skills-wp-version-tested: "7.1" wp-skills-php-min: "7.4" wp-skills-last-updated: "2026-08-22" --- # WordPress password-protected content Implement or review WordPress's built-in shared-password gate without treating it as user authentication or encrypted storage. Preserve the core form/cookie contract, guard every custom output surface, and prevent caches from publishing an unlocked representation. Read [references/runtime-and-extension-contracts.md](references/runtime-and-extension-contracts.md) when working on headless/REST access, replacing the form, changing bypass policy, or auditing exactly which core surfaces are and are not protected. ## When to use this skill - Code uses `post_password` or `has_password` to control public post, page, product, course, or custom-post-type visibility. - A template, block, shortcode, widget, email, feed, export, REST route, or GraphQL resolver displays data belonging to a protected post. - Code calls `post_password_required()`, `get_the_password_form()`, `get_the_content()`, or filters `the_password_form`. - A theme changes the protected title, invalid-password message, form markup, or WordPress 7.1 block-theme styling. - A headless or mobile client must read a protected post or its comments. - A cache/CDN serves a different response before and after password entry. - The requested feature needs one shared secret, and you must decide whether core post-password protection is strong enough. Do not trigger merely for user-password/login code or because an internal data store reuses the column, such as legacy Action Scheduler claim IDs or legacy WooCommerce order keys. Verify the field's semantic purpose before auditing it. ## Choose the correct access primitive Use the built-in post password only when all intended readers may share one secret and disclosure of the post title/permalink is acceptable. | Requirement | Appropriate mechanism | |---|---| | One shared secret gates ordinary post content | Core post password can fit. | | Per-user grant/revocation, audit, expiry, ownership, subscription, or role | Authenticated users plus capability/entitlement policy. | | Hide the post's existence from anonymous visitors | Private/custom status plus authorization, not only `post_password`. | | Protect original media URLs or downloadable files | Authorized delivery outside public uploads or signed/controlled downloads. | | High-value confidential data | Purpose-built authentication and authorization; do not rely on a shared post password. | The password is stored as plaintext in `wp_posts.post_password`; the post body is not encrypted. The browser cookie stores a salted PHPass hash, but acts as a site-scoped proof for whichever post has the matching plaintext password. Core has no per-reader identity, revocation list, attempt counter, or audit trail. ## Understand the core contract 1. A published post retains a non-empty `post_password` value. 2. Locked content renders `get_the_password_form()` rather than the body. 3. The form posts `post_password` and `redirect_to` to `wp-login.php?action=postpass`. 4. Core writes one `wp-postpass_` plus `COOKIEHASH` cookie for the site and redirects safely to the post. 5. `post_password_required( $post )` verifies that cookie hash against the post's current plaintext password. The cookie is not keyed by post ID. Posts sharing a password unlock together; entering a different password replaces the cookie, so only one distinct post password works in that browser at a time. Logged-in users do not automatically bypass the frontend check. ## Guard all custom output Check the exact owning post before reading or rendering custom data: ```php $post = get_post( $post_id ); if ( ! $post ) { return ''; } if ( post_password_required( $post ) ) { // Let the owning post template render the core form once. return ''; } return esc_html( (string) get_post_meta( $post->ID, '_acme_summary', true ) ); ``` Use the same boundary for custom fields, blocks, shortcodes, related records, downloads, JSON, email, exports, and secondary queries. Never infer access from cookie presence alone; `post_password_required()` validates it. Do not assume these operations enforce the gate: - `get_post()`, `get_post_field()`, direct post content properties, and `$wpdb`; - `get_post_meta()` or custom-table lookups; - directly applying `the_content` to a raw content string; - `get_the_post_thumbnail()`, attachment metadata, or a public uploads URL; - a custom REST/GraphQL endpoint or search indexer. Core template functions protect the ordinary content/excerpt path and several comment, feed, block-binding, and block surfaces. That is not a general data- access policy for plugin code. ## Extend the form without breaking it Prefer styling or a surgical `WP_HTML_Tag_Processor` change over rebuilding the entire form. This retains the action, redirect, unique label/input ID, error announcement, translations, and WordPress 7.1 block-theme button classes: ```php add_filter( 'the_password_form', static function ( string $html, WP_Post $post, string $invalid_password ): string { $processor = new WP_HTML_Tag_Processor( $html ); while ( $processor->next_tag( 'input' ) ) { if ( 'post_password' === $processor->get_attribute( 'name' ) ) { $processor->add_class( 'acme-post-password' ); break; } } return $processor->get_updated_html(); }, 10, 3 ); ``` If full replacement is unavoidable, preserve every contract listed in the reference and test classic plus block themes. Filter output is a trusted plugin/theme surface: escape translated text and attributes deliberately. WordPress 7.1 wraps the submit control with `.wp-block-button` and applies `.wp-block-button__link` plus the current button element class for block themes; it also enqueues the registered `wp-block-button` style. A legacy full-form replacement silently loses that improvement. ## Add bypasses as authorization policy Scope `post_password_required` narrowly. For an editorial frontend preview, an object-level edit capability is a defensible bypass: ```php add_filter( 'post_password_required', static function ( bool $required, WP_Post $post ): bool { if ( $required && current_user_can( 'edit_post', $post->ID ) ) { return false; } return $required; }, 10, 2 ); ``` Do not substitute `read_post` as a membership check: for an ordinarily published post it is commonly true for logged-in readers and does not express the intended entitlement. Prefer a dedicated capability or an explicit object-level entitlement service. Remember that this filter influences every caller, including comments, blocks, feeds, and REST preparation. ## Handle REST and headless clients deliberately Prefer the core posts endpoint for core post fields. A single-item request may supply the plaintext `password`; without it, `content.rendered` and `excerpt.rendered` are empty and their `protected` flags are true. A wrong supplied password returns `rest_post_incorrect_password` with HTTP 403. An authenticated editor using `context=edit` can access content when allowed to edit that post. Passwords in query strings can enter browser history, proxy/CDN logs, analytics, traces, and error reports. Require HTTPS, redact the parameter, do not persist it in client state, and never shared-cache a successful response. For higher-value or per-user content, use authenticated authorization rather than extending the query-password design. Custom endpoints must independently protect every additional field. A public `permission_callback` followed by an unchecked meta/custom-table read bypasses the post password even when the core posts response is correct. ## Isolate caches Core sends no-cache headers for singular posts with a non-empty `post_password`, whether currently locked or unlocked. Preserve them. A page cache or CDN that serves before WordPress runs must also bypass protected URLs or requests carrying a `wp-postpass_` cookie; never let unlocked HTML populate an anonymous cache key. Apply the same rule to REST responses, fragments, edge rendering, service workers, and headless caches. Purging after a password change does not fix a design that mixes locked and unlocked variants. ## Audit and test Test observable behavior with a temporary protected post: 1. No cookie, malformed cookie, correct cookie, wrong cookie, and expired cookie. 2. Two posts sharing a password and two posts using different passwords. 3. Anonymous, subscriber, editor without cookie, and explicitly authorized bypass behavior. 4. Content, excerpt, title, comments, featured image/media, custom meta, dynamic blocks, related records, feeds, email, exports, and custom APIs. 5. REST with absent, correct, and wrong password; `view` versus permitted `edit` context; comment endpoints where applicable. 6. Classic and block themes, invalid-password announcement, keyboard/label behavior, duplicate forms, and WordPress 7.1 button classes. 7. Origin/page cache, CDN, query logs, browser history, password changes, and a direct public media URL. Distinguish a content leak from a UI mismatch. Report file/line, post/output surface, request identity and cookie/password state, cache layer, exposed data, and the smallest policy-preserving fix. ## Severity guide - **HIGH:** locked body, custom data, comments, export, or unlocked cached response is available without the password/authorized entitlement; a file claimed to be private remains publicly downloadable. - **MEDIUM:** brute-force exposure without required controls, broad filter bypass, password-bearing URL leakage, or cache behavior that can mix variants under realistic infrastructure conditions. - **LOW:** inaccessible form, lost invalid-password feedback, missing 7.1 theme styling, overly long cookie lifetime, or misleading protected-title output. - **INFO:** the built-in shared-secret model cannot satisfy stated per-user, confidentiality, audit, or private-file requirements and needs redesign. Do not label the core form's missing nonce as CSRF by itself. The handler changes only the visitor's post-password cookie and does not grant server-side user privileges. If a customization adds account, entitlement, or persistent state changes, protect those mutations separately. ## Critical rules - Treat post-password protection as presentation gating, not encryption or user authentication. - Check the exact post with `post_password_required()` before every custom output surface. - Do not reveal protected data through public media URLs, metadata, related tables, custom blocks, APIs, feeds, emails, or caches. - Never test only cookie presence; verify through core or an equivalent explicit password check at an API boundary. - Never shared-cache unlocked HTML or a successful password-bearing response. - Preserve the form action, field names, safe redirect, accessibility, and 7.1 block-theme behavior when customizing markup. - Do not log plaintext post passwords or return `post_password` in public API responses. - Prefer authenticated object-level authorization when requirements exceed one shared site-scoped secret. ## Cross-references - Run **`wp-rest-api`** for a custom protected REST resource or headless route. - Run **`wp-security-audit`** for capability, output, custom download, and endpoint review around the gate. ## What this skill does NOT cover - WordPress login passwords, reset flows, Application Passwords, or Basic Auth. - Membership, paywall, DRM, document-room, or per-user entitlement systems. - Making public uploads private merely because their parent post is protected. - Whole-site password protection or HTTP server authentication. ## References - Detailed runtime, output, hook, REST, and extension contracts: [references/runtime-and-extension-contracts.md](references/runtime-and-extension-contracts.md) - Official core references: [`post_password_required()`](https://developer.wordpress.org/reference/functions/post_password_required/), [`get_the_password_form()`](https://developer.wordpress.org/reference/functions/get_the_password_form/), and the [posts REST endpoint](https://developer.wordpress.org/rest-api/reference/posts/). - WordPress 7.1 protected-form accessibility note: <https://make.wordpress.org/core/2026/08/13/accessibility-improvements-in-wordpress-7-1/> - Verified WordPress 7.1 source paths: - `wp-includes/post-template.php`, `wp-login.php`, `wp-includes/class-wp.php`, `wp-includes/class-wp-query.php`, `wp-includes/comment.php` - `wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php`, `wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php` - `wp-includes/block-bindings/post-meta.php`, `wp-includes/block-bindings/post-data.php`
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.