Membership and Gated Content in WordPress: Architecture First
How to architect a WordPress membership site that does not leak: entitlement models, cache bypass rules, protected file delivery, paywall SEO and billing
A client called us in a panic last spring: their paid research library, roughly 900 gated PDFs and 300 member-only posts, had been fully indexed by Google. Not the teasers. The whole thing. The cause was boring. Cloudflare had cached a member-rendered page because a cache rule had been edited six weeks earlier and the bypass cookie no longer matched what the membership plugin was setting.
Nobody had broken into anything. The access check worked perfectly in PHP. It just never ran, because the HTML was served from an edge node 40ms away and PHP was never invoked at all.
That is the thing nobody tells you about building a wordpress membership site: the plugin is the easy part. The hard parts are caching, file delivery, query leakage and billing state, and all four of them live outside the plugin’s settings screen. Get the architecture right and almost any membership plugin will do. Get it wrong and the best plugin on the market will still leak.
- Entitlements and WordPress roles are different things. Store entitlements in your own table keyed by user and plan, treat the billing provider as the source of truth, and never grant a capability like
readprivatepostsas a membership mechanism. - Full page caching and gated content conflict by definition. Pick one of three strategies: bypass cache for anyone holding a membership cookie, cache per tier using a cookie segment in the cache key, or render the shell publicly and fetch gated fragments over an authenticated request.
- Filtering
the_contentis not access control. Search, archives, REST, feeds, oEmbed and XML sitemaps all reach post data on paths that never touch that filter. Gate at the query and template level instead. - Files in
wp-content/uploadsare world readable over HTTP regardless of what the post says. Move protected assets out of the web root and serve them throughX-Accel-Redirector an equivalent, with a signed, short-lived URL. - For SEO, use Google’s flexible sampling: a real lead-in or metered allowance, plus
isAccessibleForFree: falseand acssSelectoron the paywalled section. Serving Googlebot the full article while humans see a teaser is cloaking and gets caught.
Model the entitlement, not the plugin
Before you install anything, answer one question: what exactly does a member buy? The answers fall into four shapes, and they have genuinely different data models.
- Time-boxed access to everything. One plan, one flag, expiry date. Simplest possible model.
- Tiered access. Bronze, silver, gold, where higher tiers are supersets. Still simple if you keep tiers ordered and compare rank, not membership in a set.
- Entitlement per product. Someone bought course A and workshop C but not B. This is a many-to-many relationship, not a tier. Model it as tiers and you will be writing exceptions forever.
- Metered. Five articles a month, then pay. This needs a counter with a reset window, and it needs to work for anonymous visitors, which means it is a cookie or edge concern, not a user table concern.
Whichever shape you pick, build a single access function and make every other part of the system call it. Not the template, not the REST filter, not the download handler. One function.
/**
- The only place that answers "can this user see this post".
- Templates, REST filters, query filters and file handlers all call this.
*/
function acmecanaccess( int $postid, ?int $userid = null ): bool {
$userid = $userid ?? getcurrentuser_id();
$required = getpostmeta( $postid, 'acmerequiredplan', true );
if ( '' === $required ) {
return true;
}
if ( ! $user_id ) {
return false;
}
$plans = acmeactiveplans( $user_id );
// Filter last so add-ons can grant access, never so they can silently revoke it.
return (bool) applyfilters( 'acmecanaccess', inarray( $required, $plans, true ), $postid, $userid );
}
function acmeactiveplans( int $user_id ): array {
$cached = wpcacheget( $userid, 'acmeplans' );
if ( false !== $cached ) {
return $cached;
}
global $wpdb;
$plans = $wpdb->get_col( $wpdb->prepare(
"SELECT planslug FROM {$wpdb->prefix}acmeentitlements
WHERE user_id = %d AND status = 'active'
AND ( expiresat IS NULL OR expiresat > UTC_TIMESTAMP() )",
$user_id
) );
wpcacheset( $userid, $plans, 'acmeplans', 300 );
return $plans;
}
Use a custom table, not user meta. Entitlements have a lifecycle: created, renewed, cancelled, refunded, disputed. You will want to query across users for reporting. Jamming that into wp_usermeta as serialised arrays is a decision you regret about four months in, usually the moment someone asks how many gold members churned last quarter.
On plugin choice
Paid Memberships Pro if you want something hackable with decent hooks and no licence drama. MemberPress if the client needs a polished admin and rules UI and will not be touching code. WooCommerce Memberships only if WooCommerce is already in the build for real commerce reasons, because dragging the whole Woo stack in for access control alone adds a lot of surface area. LearnDash or a dedicated LMS if the content is structured courses with progress. And whichever you choose, run a plugin audit afterwards, because membership plugins ship a surprising amount of optional machinery that loads on every request.

A wordpress membership site is really a cache invalidation problem
On a normal content site you cache aggressively at the edge and your origin barely wakes up. On a gated site the same page has at least two representations and possibly five, and the edge has no idea which one it is holding.
There are three workable strategies.
Bypass for members. Any request carrying a membership cookie skips the cache entirely and hits PHP. Simple, safe, and usually the right answer for sites under a few thousand concurrent members. The cost is real: edge-cached anonymous pages return in roughly 50 to 80ms, while the same page rendered by PHP for a logged-in member lands somewhere between 350 and 600ms depending on how chatty the membership plugin is. Members accept that. Anonymous visitors, who are the ones you are converting, still get the fast path.
Segment the cache key. Add a normalised tier cookie (acme_tier=gold) to the cache key so each tier gets its own cached copy. This works well when tiers are few and content per tier is identical. It fails the moment you have per-product entitlements, because the cardinality explodes and your hit rate collapses.
Public shell, authenticated fragment. Cache the page publicly with a placeholder where the gated content goes, then fetch the real content client-side from an authenticated REST route. Best hit rate, highest complexity. You also need to handle the flash of locked content. This approach suits high-traffic news-style builds and very little else.
Whatever you pick, the cookie that drives it must be set by your code, not inferred from the plugin’s internals. Set it on login and on entitlement change, clear it on logout and expiry, and write a single integration test that asserts an anonymous request to a gated URL returns the teaser with a Cache-Control: public header while a member request returns the full body with private, no-store.
addaction( 'sendheaders', function () {
if ( issingular() && ! acmecanaccess( getqueriedobjectid() ) ) {
return; // Teaser is public and cacheable.
}
if ( isuserlogged_in() ) {
header( 'Cache-Control: private, no-store, max-age=0' );
header( 'Vary: Cookie' );
}
} );
Close the leak paths before you ship
Here is the pattern that keeps failing. A developer hooks the_content, truncates it, appends a “subscribe to continue” box, and ships. Then six weeks later someone finds the full text in the site’s own search results, or in the JSON feed, or in a Google cache of the REST endpoint.
Run this checklist against every gated post type:
- Archives and search. Use
pregetpoststo exclude gated posts from the main query for users who cannot access them, or accept that titles and excerpts are public and write excerpts that are safe to publish. Decide deliberately. Half the leaks we find are excerpts nobody thought of as content. - REST API. Gated post types should not be
showinrestunless you have filtered them. If you need REST for the editor, filterrestprepare{$post_type}and stripcontent.renderedandexcerpt.renderedfor unauthorised users. - Feeds. RSS runs
thecontent, so a content filter does cover it, but the safest move is excluding gated posts from feeds entirely withpregetpostsonisfeed(). - XML sitemaps. Core sitemaps and every SEO plugin will happily list member-only URLs. If the teaser is a real public page, keep them. If the URL 302s to a login screen, remove them.
- Revisions and autosaves.
?preview=trueand the revision diff screen bypass most content filters. Capability checks cover this, but only if you have not handed members an editor role. - oEmbed.
/wp-json/oembed/1.0/embed?url=returns titles and thumbnails for any public post.
Protected files need a real gate, not a hard-to-guess URL
Everything in wp-content/uploads is served directly by nginx or Apache. PHP never runs. No plugin can protect it by hooking anything, and renaming files to a hash is not access control. It is obfuscation that survives exactly one shared link.
The fix is to put protected media outside the web root and stream it through a PHP handler that delegates the actual byte pushing back to the web server. On nginx that is X-Accel-Redirect. On Apache with mod_xsendfile it is X-Sendfile.
# Only reachable via an internal redirect from PHP, never directly over HTTP.
location /protected-internal/ {
internal;
alias /var/www/acme/private-media/;
}
addaction( 'templateredirect', function () {
if ( ! getqueryvar( 'acme_download' ) ) {
return;
}
$fileid = (int) getqueryvar( 'acmedownload' );
$postid = (int) getpostmeta( $fileid, 'acmeparent_post', true );
if ( ! acmecanaccess( $post_id ) ) {
status_header( 403 );
wp_die( 'This file is for members only.', 403 );
}
$relative = getpostmeta( $fileid, 'acmeprivatepath', true ); // e.g. reports/2026-q1.pdf
nocache_headers();
header( 'Content-Type: application/pdf' );
header( 'Content-Disposition: attachment; filename="' . basename( $relative ) . '"' );
// nginx takes over from here; PHP exits immediately and uses no memory for the file.
header( 'X-Accel-Redirect: /protected-internal/' . $relative );
exit;
} );
If your host is managed WordPress and will not let you touch nginx config or write outside the web root, use object storage with signed URLs instead. Generate a 60 second presigned S3 or R2 URL after the access check and redirect. Same security model, no server access needed, and it scales better for large files anyway.
Gating at the block level
Post-level gating is coarse. Most real editorial teams want “these three paragraphs are free, the rest is not”, and in a block theme that is cleanly solved with a block attribute plus a render_block filter.
addfilter( 'renderblock', function ( string $block_content, array $block ): string {
$required = $block['attrs']['acmeRequiredPlan'] ?? '';
if ( '' === $required ) {
return $block_content;
}
if ( inarray( $required, acmeactiveplans( getcurrentuserid() ), true ) ) {
return $block_content;
}
// Render the upsell once, on the first gated block only.
static $shown = false;
if ( $shown ) {
return '';
}
$shown = true;
return acmerenderpaywall_cta( $required );
}, 10, 2 );
Two caveats. First, the attribute lives in postcontent, so anyone with edit access to the raw content sees it. That is fine, but do not use the attribute value to store anything sensitive. Second, this filter runs on REST rendering too, which is what you want for the front end but not inside the editor. Guard with ! isadmin() && ! wpisjson_request() if your editor starts showing paywall CTAs. If you are new to how block output is assembled and overridden, our notes on template hierarchy in block themes cover where these hooks sit relative to templates and parts.
Paywall SEO: flexible sampling, not cloaking
Google retired First Click Free in 2017 and replaced it with flexible sampling, which gives you two sanctioned options: metering (a fixed number of free articles per visitor per period) or lead-in (a genuine, substantial opening section of every article shown to everyone, including crawlers).
Mark up the gated portion so Google knows what it is looking at rather than guessing you are keyword stuffing a stub:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "Q1 2026 Supply Chain Review",
"isAccessibleForFree": false,
"hasPart": {
"@type": "WebPageElement",
"isAccessibleForFree": false,
"cssSelector": ".acme-gated"
}
}
</script>
The cssSelector must match an element that actually wraps the gated content in the HTML you serve to crawlers. If you strip the gated markup entirely for anonymous requests, the selector points at nothing and the markup is useless. Wrap the paywall CTA in that class instead.
What you must not do is detect Googlebot by user agent and serve it the full article. That is cloaking, it is detectable through the URL inspection tool rendering mismatch, and the penalty is not worth the traffic. A hard paywall wordpress setup with a weak lead-in will rank worse than a metered one. That is a product decision as much as a technical one, and it belongs in the kickoff meeting, not the launch checklist.
Billing is a distributed system, treat it like one
Your database and Stripe’s database will disagree. Not might. Will. Webhooks get delayed, retried out of order, delivered twice, or dropped while your origin is restarting after a deploy.
Four rules that have saved us repeatedly:
- Idempotency. Store the provider event ID with a unique index and no-op on duplicates. Stripe retries for up to three days and you will see the same event twice.
- Verify, do not trust the payload. On receiving
customer.subscription.updated, call the API and read the current subscription state rather than applying the delta in the webhook body. Out-of-order delivery makes deltas dangerous. - Grace periods are an entitlement state, not a cron job. Add a
statusofpast_duethat still grants access for N days and shows a banner. Revoking access the instant a card fails costs you members who would have updated the card. - Reconcile nightly. A WP-Cron or, better, a system cron task that pulls all active subscriptions and compares them against your entitlements table. Log divergences. On a site with a few thousand members we typically see one to three divergences a week, every one of them a missed webhook.
Separately: your public marketing surface, the pricing page, the sales pages, the member dashboard shell, should not be entangled with your access logic. We build those on CanvasWP and keep the gating in a small site-specific plugin, so a designer can rebuild the pricing page on a Friday without touching anything that could expose content.
Frequently Asked Questions
Can I just use WordPress roles and capabilities for membership?
No, and this is the most common architectural mistake we see. Roles are coarse, global and designed for editorial permissions, so a member with a custom role that can read private posts can read every private post on the site, including drafts from other authors. Keep members on the subscriber role and put entitlements in your own table.
Does gated content hurt my search rankings?
Not if you implement flexible sampling correctly with either metering or a substantial lead-in plus the right structured data. Gated pages typically rank below equivalent open pages because engagement signals are weaker, but they still rank. The real damage comes from thin teasers that are 60 words of nothing followed by a login form.
How do I stop members sharing their login?
Track concurrent sessions per account using WordPress session tokens and cap them at two or three, forcing the oldest to log out when the limit is exceeded. IP-based blocking causes far more false positives than it catches sharers, especially on mobile networks. Most of our clients land on a soft cap plus an email notification rather than hard enforcement.
Will a full page cache plugin like WP Rocket or LiteSpeed work with a membership site?
Yes, but you must verify the cookie exclusion rules yourself rather than trusting the plugin’s preset. Log in as a member in one browser, load a gated page, then request the same URL with curl and no cookies, and diff the two responses. Put that check in your deploy pipeline, because cache rule drift is exactly what caused the indexing incident at the start of this article.
Headless WordPress for a membership site: good idea?
Only if you already have a strong reason to go headless, because gating makes it meaningfully harder. You now need authenticated API requests, token storage on the client, and a server-side rendering layer that can make access decisions. Without that, gated content travels to the browser in the JSON payload where anyone can read it in devtools. Rendering gated content client-side from an unauthenticated endpoint is not a paywall.
Where to start
Spend an afternoon on the entitlement model and the caching strategy before you install a single plugin. Write the access function first, write the test that proves an anonymous curl request cannot see gated HTML second, and only then pick the plugin that fills in the billing and admin UI around it.
If you have an existing gated content setup, the fastest useful thing you can do today is run that curl diff against your five most valuable member-only URLs, then check whether your protected PDFs are reachable by pasting the upload URL into a private window. Those two checks catch most of what we find on audits, and both take about ten minutes.


