A lock made of CSS and a mutation observer
A premium content gate built from an overlay div is not a lock. It's a suggestion, and any browser's devtools decline the suggestion in about four seconds.
By Meet ModiSay you're building a premium content gate on a video-learning feature. The obvious approach: render the locked lesson underneath, drop a translucent overlay div on top with a paywall message, disable pointer events on the video, done.
It takes four seconds to defeat. Open devtools, select the overlay element, hit delete. The video underneath was never actually locked. It was just covered.
Why the overlay approach fails structurally
The overlay is a styling decision, not an access control decision. The locked content is fully present in the DOM, fully interactive, and the only thing standing between a user and it is an element with a z-index. Deleting a node, or just editing its parent to remove the child, is native browser behavior. No extension, no console script, no network request. It's the same skill required to inspect any element on any page.
// what a curious user does, no tooling beyond devtools
const overlay = document.querySelector('.premium-gate-overlay');
overlay.remove();
// the video element underneath was never disabled at the platform level,
// so it's now fully playableCSS-only locks (opacity, pointer-events: none, a high z-index overlay) all share this property: they change appearance, not capability. The element underneath remains focusable, clickable, and scriptable unless something tells the browser otherwise.
A more resilient version
The inert attribute is a platform-level primitive, not a style. An element marked inert (and everything inside it) becomes unfocusable, unclickable, and invisible to assistive tech, regardless of what CSS says about it. Removing the overlay div no longer does anything, because the overlay was never the thing doing the locking.
const lockedRegion = document.querySelector('.lesson-content');
lockedRegion.inert = true;
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
const removedTheOverlay = [...m.removedNodes].some(
(n) => n.nodeType === 1 && n.matches?.('.premium-gate-overlay')
);
const strippedInert = m.type === 'attributes' && m.attributeName === 'inert' && !lockedRegion.inert;
if (removedTheOverlay || strippedInert) {
lockedRegion.inert = true;
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['inert'],
});The MutationObserver watches for exactly the edits a curious user tends to make: deleting the overlay, editing its parent, or flipping inert back off directly. When any of those happen, the lock re-applies on the next tick. Someone still can defeat this if they're determined (disable JS entirely, intercept the observer's callback before it registers, patch MutationObserver itself), but the casual delete-the-div move stops working.
Questions people actually ask
Doesn't a client-side lock get defeated by anyone who really wants to?
Yes. Nothing running in a browser the user controls is a real boundary. Turn off JavaScript, intercept fetch, replay a captured response, or just read the video URL out of the network tab and hit it directly. The inert-plus-observer approach raises the floor from 'four seconds with devtools' to 'requires actually thinking about it,' which matters for the population of users who'd casually poke around but wouldn't go further. It does nothing against someone who scripts around it.
What does the inert attribute actually do that CSS alone doesn't?
CSS controls rendering. inert controls the accessibility tree and the input event pipeline directly, at the browser engine level, independent of any stylesheet. An element can be fully visible and normally styled and still be unfocusable and unclickable if it's inert. That's the opposite of the overlay approach, where the element is fully capable and only visually obscured. Tab order skips inert subtrees too, which a z-index overlay never handled correctly anyway.
What's the actual threat model this defends against?
Ordinary users who right-click, hit inspect out of curiosity, and delete the thing in their way, then try to press play. That's a much larger group than people who'd write a script against your API. The observer pattern is aimed at casual tampering, not adversarial reverse engineering. If your threat model includes someone willing to write code against your backend, no client-side change moves the needle at all.
Should the server also re-check on every locked action?
Always, for anything with a real payoff. Serving the actual video file, returning transcript text, starting a download, any of that needs an entitlement check on the backend request, independent of whatever the DOM looks like. The client-side lock is about experience and casual deterrence. The server-side check is the only thing that's actually a security boundary. If the video URL is guessable or unguarded once you have it, the DOM lock was theater.
What I'd do differently
I'd stop thinking of the client-side gate as security at all and just call it UX. Once it's framed that way, the design questions get easier: how do we make casual tampering not work, without pretending it's the actual defense. The actual defense was always going to be the backend check on the request that serves the file. Building the DOM lock well is worth doing anyway, because most of your users aren't attackers, they're just curious, and a lock that falls over in four seconds teaches them that your gates in general aren't worth respecting.
Anything the client is trusted to enforce is not enforced. The server-side version of the same mistake is a public endpoint that accepted an ID meant for one internal caller.
More Posts
Two services, one topic, either deploy order
A shared topic is only safe if the old consumer ignores what the new producer adds. One field with a default, and either side can roll back alone.
Scale on what's queued and you'll remove the pod mid-job
An autoscaler counting queued work drops to zero the moment pods claim it, so it scales into running jobs and each removal hands the work back to start over.
The model was starting and the first job didn't know
A state flag set inside the background thread instead of before launching it, so the caller that asked for the model read 'stopped' and took the slow path.