Meet Modi
Back to Blog
·7 min

A lock made of CSS and a mutation observer

Say you're building a premium content gate. An overlay div is not a lock. It's a suggestion, and any browser's devtools can decline the suggestion in about four seconds.

By Meet Modi
Frontend SecurityDOMUX Patterns

Say 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 playable

CSS-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.

More Posts