The Paywall That Wasn't: Debugging a 919-Video Leak on WordPress
The Stack
The site is a fitness membership platform. The relevant pieces:
- WordPress + Divi (child theme, Theme Builder for templates)
- WooCommerce Subscriptions for billing - this is where members actually pay
- WishList Member for membership levels and content protection
- All-in-One Video Gallery (AIOVG) for the on-demand library - videos live in a custom post type,
aiovg_videos, with their own taxonomy - Vimeo for the actual video hosting (private embeds)
- Cloudways / DigitalOcean with Varnish full-page cache
- Code Snippets for small PHP additions
Nine hundred and nineteen videos. Hold that number; it matters later.
Symptom One: The Reported Bug
The complaint was straightforward to reproduce. Log in as a real paying member, navigate to a video, and the page shows: "You must have a Rev6 Membership to access this video." Annoying, but it reads like a simple access-check bug.
My first instinct was the membership level. The site had recently renamed a membership tier, and that's a classic trap: WishList Member level IDs are stable across renames, but level names are not. Any code or protection rule that matches a level by name silently breaks the moment someone renames it in the admin.
So the working theory was: the video gate checks for a level by name, the name changed, every member now fails the check. Reasonable. Wrong, as it turned out - but reasonable.
Symptom Two: The Bug Nobody Reported
Before writing a fix, I checked the obvious counterfactual: what does a non-member see? I opened the same video in an incognito window, fully logged out. The "you must have a membership" notice showed up - and then the video played anyway.
That reframed everything. The two symptoms were:
- Logged-in members saw the membership notice (the reported bug).
- Logged-out visitors also saw the notice - but the player rendered and played regardless (the unreported bug).
Scrolling down on the member's page confirmed it: the "paywall" was a block of text sitting above a video that rendered unconditionally for everyone. It wasn't a gate. It was a sign that said "members only" taped to an unlocked door.
The real situation: all 919 videos were publicly accessible. Anyone with a link could watch the entire paid library, logged in or not. The membership notice was decorative, and its own broken logic happened to display it to members too - which is the only reason anyone noticed something was wrong.
This is the first lesson, and it's the one I keep relearning: Always check the inverse of the reported symptom. "Members are blocked" and "non-members get in" can be the same root cause wearing two masks. The reported half is rarely the dangerous half.
Why It Was Wide Open
The videos are an AIOVG custom post type. WishList Member's content protection was configured on the parent On-Demand page, with "automatically apply protection to child content" enabled. But WLM's cascade follows the WordPress page/post hierarchy. The AIOVG videos are a separate custom post type - not children of that page in any way WLM understands. So the cascade never reached them.
The parent page looked protected in the admin; the 919 videos under it were never touched. Two systems, each assuming the other had it handled, and the content fell through the gap between them.
The Fix: One Gate, Server-Side, Before Render
The instinct with a Divi site is to reach for the template and conditionally hide the player. Don't. Hiding markup client-side still ships the source URL in the response - the player's gone visually, the video link isn't. And template-level conditionals are fragile across the half-dozen ways a CPT can get rendered (archive autoplay, shortcodes, REST).
The correct interception point is template_redirect, early, before AIOVG or Divi build anything. If the visitor isn't entitled, you redirect and exit - the player HTML never enters the response at all.
add_action( 'template_redirect', function () {
if ( ! is_singular( 'aiovg_videos' ) ) {
return;
}
$uid = get_current_user_id();
if ( $uid && user_can( $uid, 'manage_options' ) ) {
return; // admins always pass
}
$can_view = $uid && function_exists( 'wcs_user_has_subscription' ) && wcs_user_has_subscription( $uid, '', 'active' );
if ( $can_view ) {
return;
}
if ( is_user_logged_in() ) {
wp_safe_redirect( home_url( '/membership/' ) ); // logged in, no sub
} else {
wp_safe_redirect( add_query_arg( 'redirect_to', urlencode( get_permalink() ), home_url( '/login/' ) ) ); // logged out - return to the video after sign-in
}
exit;
}, 1 );
Because the gate keys on the post type (is_singular('aiovg_videos')), it covers all 919 videos at once. New uploads are gated on publish. There's no per-video work, ever.
The Decision That Fixed the Reported Bug: Stop Trusting the Level
Notice what the gate checks: wcs_user_has_subscription(), not a WishList Member level. This is the actual fix for "paid members can't play."
The original setup gated on WLM levels, and the allowed-level list didn't include every level paying members were actually on. A member subscribed and paying - but sitting on a level that wasn't in the approved list - got rejected. The rename made it worse, but the deeper problem was architectural: Your billing truth and your access truth were two different systems.
Members pay in WooCommerce. Access was checked in WishList Member. Those only stay in sync if every Woo→WLM hook fires perfectly, forever. They don't.
Gating on the source of truth - does this person have an active subscription? - sidesteps the entire sync problem. It also matched the business rule once I confirmed it with the client: no tier should be blocked from videos. "Has an active subscription" is exactly that rule, expressed in code, and it's immune to level renames because it never looks at levels.
Once the snippet was the sole authority, I disabled WLM's content protection on the video CPT and the On-Demand pages. Two gates fighting each other is how you get a member blocked by one while the other tries to let them in. Pick one source of truth and delete the other.
The 500 That Wasn't in the Log I Was Reading
Mid-debugging, the video pages started throwing "There has been a critical error on this website" - a PHP fatal, while I was logged in as admin. I pulled the Cloudways logs and found... nothing useful.
Pages of the log buffer is full (1024) truncation warnings (cosmetic, caused by my own long admin-search URLs) and directory index forbidden 403s (Googlebot hitting locked directories - security working as intended). Noise.
The lesson here is unglamorous but cost me time: A "critical error" is a PHP fatal, and PHP fatals go to the PHP error log - not the Nginx access/error log. I was reading the web-server log looking for an application error. Wrong file. WP_DEBUG_LOG writing to wp-content/debug.log is the fast path to the actual fatal line.
The fatal was a WishList Member function that existed (so my function_exists() guard passed) but threw when invoked on that version. The clean resolution was to remove the WLM call from the gate entirely - which I wanted to do anyway, since I'd already moved access onto WooCommerce. The fatal disappeared because the code that could fatal was gone, not because I patched around it.
The Edge Cases That "Done" Hid
With the gate live and three test accounts passing (admin, a paying member, a logged-out visitor correctly bounced), it looked finished. It wasn't. The membership model had more shape than the ticket implied, and each wrinkle was a chance to wrongly lock out a paying customer.
Multiple subscription tiers, one rule. All-Access, On-Demand, and Vitality Individual are all WooCommerce subscriptions. Gating on "any active subscription" covered all three without enumerating them - which is the point of gating on the source of truth rather than a list you have to maintain.
A legacy label. A member on an old "Gold Membership" level got redirected. The client clarified that "Gold" was simply the old name for the All-Access membership - these are current, paying members. The redirect was a bug, not correct behavior.
A non-"active" status. When I checked that member in WooCommerce → Subscriptions, the subscription existed but its status was on-hold (a failed or retrying payment), not active. My gate counted only active, so it bounced her. Given the client's explicit priority - "first make sure members have access, then worry about locking others out" - I widened the accepted statuses to include on-hold and pending-cancel, the grace-period states where someone is reasonably still a paying member:
$can_view = wcs_user_has_subscription( $uid, '', 'active' )
|| wcs_user_has_subscription( $uid, '', 'on-hold' )
|| wcs_user_has_subscription( $uid, '', 'pending-cancel' );
That last one is worth flagging as a product decision, not just a code one: including on-hold means a member whose card permanently fails keeps access until the subscription fully cancels. That's a deliberate "members-first" trade, and the kind of thing you surface to the client rather than bury as a silent default.
"It works for my three test accounts" is not "it works." The accounts that break your assumptions are the ones with unusual status, not unusual identity - the on-hold member, the legacy tier, the trialing user. Go find those specifically.
The Part the Page Gate Doesn't Solve
There's a difference between a UX paywall and a security paywall. The template_redirect gate stops the player from rendering - but it protects the page, not the file. If the underlying video URL is public, a leaked direct link still plays, gate or no gate.
Here the videos are on Vimeo as private embeds, with a privacy hash in the URL. That's meaningfully better than raw public MP4s: the gate already stops non-members from reaching the page and grabbing the link in the first place. To fully close it - so a copied link won't play anywhere else - the remaining step is Vimeo's domain-level privacy: restrict embedding to the site's own domain. Then even a leaked URL refuses to play off-site.
That's a settings change, not a build - but it requires access to the Vimeo account, which is the client's to grant. The honest version of "done" names what's closed and what's pending, rather than letting "the player is hidden" be mistaken for "the content is secure."
What I'd Take to the Next One
- Check the inverse of the reported symptom before writing any fix. The reported half is rarely the dangerous half.
- Gate on the source of truth. Bill in one system, check access in that same system. Downstream copies drift.
- One gate, not two. Two access systems on the same content will eventually disagree, and the disagreement is always a blocked customer or a leaked file.
- Read the right log. PHP fatals are in the PHP log, not the web-server log.
- Intercept before render, and exit. Hiding markup isn't protection if the source URL still ships.
- Test by status, not by identity. On-hold, legacy, trialing - the weird states are where paying members get wrongly locked out.
- Distinguish the UX paywall from the security paywall, and tell the client which one they actually have.
The ticket said "some members can't play videos." What it meant was "our entire paid catalog has been free for anyone with a link, and the only reason we noticed is that the broken sign was also annoying the people who paid." Both true. The second framing is the one worth fixing for.
Comments
No comments yet. Start the discussion.