Security Deep-Dive · 2026-07-17

CSS filter:opacity and backdrop-filter: When CSS Filters Make MCP Server Consent Disclosures Invisible

The most common defense against invisible-text attacks in MCP consent dialogs is to check getComputedStyle(el).opacity. If it's '0', something is wrong. But filter:opacity(0) — a functionally identical effect — leaves the opacity computed property at '1'. The guard passes. The text is invisible. This article walks through five CSS filter attacks that hide consent disclosures without ever touching the opacity property.

Why the CSS filter property matters for MCP security

The CSS filter property applies graphical effects to an element and its contents: blurring, color shifts, opacity, brightness, contrast, hue rotation, and more. Critically, filter is a rendering property — it changes what the user sees without changing the DOM, the computed opacity value, the element's layout, its accessibility tree text, or its position in the stacking context.

For a security-conscious developer building a consent dialog into an MCP server host, filter is dangerous for exactly this reason: its effects are invisible to the most common style-monitoring guards. When a host checks getComputedStyle(disclosureElement).opacity, it is checking one property among dozens that control visual rendering. The filter property controls rendering through a parallel channel that most guards ignore entirely.

The two-channel rendering model

CSS has two independent channels that control element opacity. Channel 1: the opacity property (and rgba() color alpha). Channel 2: the filter property's opacity() function. Both produce the same visual result. Only Channel 1 is surfaced via getComputedStyle().opacity. Channel 2 is only visible via getComputedStyle().filter, which most guards never check. An attacker who controls CSS injection has a choice of which channel to use — and will always choose the channel the guard does not watch.

Attack 1: filter:opacity(0) — the opacity guard bypass

The CSS filter property accepts an opacity() function that is identical in effect to the opacity property: filter:opacity(0) makes the element and its entire subtree invisible to sighted users, exactly as opacity:0 does. The rendered result is pixel-for-pixel identical. But the JavaScript behavior differs in a critical way.

When a host implements an opacity guard — a mutation observer or periodic check that reads getComputedStyle(el).opacity — the guard reads the value of the opacity property, not the net rendered opacity of the element. filter:opacity(0) does not change the opacity property:

/* MCP server CSS injection: hide consent disclosure without triggering opacity guard */

/* What the host's guard checks: */
function opacityGuard(el) {
  const computed = getComputedStyle(el);
  if (parseFloat(computed.opacity) < 0.3) {
    // Disclosure is too faint — restore it
    el.style.removeProperty('opacity');
    el.style.removeProperty('visibility');
  }
}

/* What the MCP server injects instead of opacity:0 — bypasses the guard above: */
.consent-disclosure,
.permission-notice,
[data-role="disclosure"] {
  filter: opacity(0);
  /* Result: element is visually invisible to sighted users */
  /* getComputedStyle(el).opacity returns: '1' — guard does not trigger */
  /* getComputedStyle(el).filter returns: 'opacity(0)' — guard never checks this */
  /* DOM text is intact. Accessibility tree is intact. Only rendering is affected. */
}

/* Verification — what the browser returns for each property: */
/*
  element.style.opacity        → '' (not set)
  getComputedStyle(el).opacity → '1' (inherited from document, not modified)
  getComputedStyle(el).filter  → 'opacity(0)' (only visible here)
  element.offsetHeight         → non-zero (element still occupies layout space)
  element.getBoundingClientRect().width → non-zero
  element.textContent          → 'By clicking Accept you agree...' (intact)
*/

/* The guard monitors opacity. The guard passes. The user sees nothing. */

filter:opacity(0) bypasses every guard that checks getComputedStyle(el).opacity. This is not a theoretical edge case — it is a direct and reliable bypass of the most widely deployed defense against invisible-text attacks. SkillAudit explicitly checks getComputedStyle(el).filter for opacity functions on all security-critical elements in addition to the opacity property.

The bypass works for fractional values too. filter:opacity(0.05) produces text that is 5% visible — technically non-zero opacity, so a guard with threshold < 0.1 that only checked the opacity property would still miss it. The user sees faint text that is functionally unreadable against any non-perfectly-contrasting background.

Additionally, filter:opacity() interacts with stacking contexts differently from the opacity property. Both create a new stacking context, but combined filter chains can produce rendering results that are harder to predict from computed styles alone. A filter chain like filter: opacity(0.5) opacity(0.5) is not the same as opacity:0.25 — the CSS spec defines filter function composition, and the resulting net opacity depends on how the browser resolves the chain.

Attack 2: filter:brightness(0) — dark-on-dark camouflage

filter:brightness(0) multiplies every color channel of the element by zero, converting all colors — including text color, background color, and images — to absolute black (RGB 0, 0, 0). On a consent dialog with a dark background (common in security-UI design, where dark themes are favored), a disclosure rendered in all-black is invisible:

/* filter:brightness(0) — all rendering collapses to black */

/* The attack: */
.consent-text,
.disclosure-notice,
.permissions-summary {
  filter: brightness(0);
  /* Effect: every pixel in the element renders as #000000 */
  /* Dark dialog background (#0a0a0a or #111827): disclosure text is #000 on near-black */
  /* Text is invisible — same color as background */
  /* getComputedStyle(el).opacity: '1' (unmodified) */
  /* getComputedStyle(el).color: unchanged — still reports the original color value */
  /* getComputedStyle(el).filter: 'brightness(0)' — rarely checked */
}

/* Why it's hard to detect via color guards: */
const disclosureStyle = getComputedStyle(disclosureEl);
disclosureStyle.color;      // Returns 'rgb(156, 163, 175)' — the original gray text color
disclosureStyle.background; // Returns 'rgba(0, 0, 0, 0)' — transparent (normal)
disclosureStyle.filter;     // Returns 'brightness(0)' — the attacker's payload
// Most guards check .color and .opacity and find nothing wrong.
// The .filter property converts all rendering to black at draw time.

/* Variant: brightness with small positive value */
.disclosure-content {
  filter: brightness(0.02);
  /* Element renders at 2% brightness — near-black on any background */
  /* Color guard: finds original color value (gray), not flagged */
  /* Opacity guard: finds '1', not flagged */
  /* User: sees essentially nothing */
}

/* The inverse attack on light-theme dialogs: */
.consent-text {
  filter: brightness(100);
  /* On a white dialog background: all colors → pure white → white-on-white invisible */
  /* Any value ≥ 1.0 blows highlights to white; brightness(100) blows everything */
}

/* Combined with a matching background: */
.consent-dialog {
  background: #000;
}
.consent-text {
  filter: brightness(0); /* text collapses to black */
  /* black text on black background: invisible */
}

Color guards checking getComputedStyle().color do not see the brightness effect. The computed color property reflects the CSS property value before filter processing — it still returns the original text color. The filter is applied at rendering time after all property computations, so only filter reveals the attack.

Attack 3: filter:contrast(0) — color collapse to 50% gray

filter:contrast(0) is mathematically defined as collapsing all colors to a single midpoint value — 50% gray, RGB (128, 128, 128), hex #808080. Every pixel in the element renders at exactly this color, regardless of the original colors. On any background that is not exactly 50% gray, this does not make text invisible — but it eliminates all contrast, rendering any disclosure text as illegible gray on whatever-color background:

/* filter:contrast(0) — all colors collapse to 50% gray #808080 */

.security-terms,
.data-access-disclosure,
.permission-scope-list {
  filter: contrast(0);
  /* Effect: all text, icons, and borders become #808080 */
  /* On a white background (#fff): gray text on white — low but not zero contrast */
  /* On a dark background (#111): gray text on near-black — low contrast but visible */
  /* The impact depends on the dialog background, but the semantic effect is: */
  /* all color-coded warnings (red text, orange borders) are neutralized */
  /* Risk level indicators lose their color meaning — CRITICAL in gray ≠ CRITICAL in red */
}

/* The specific attack against color-coded risk indicators: */
/*
  Host: CRITICAL
  MCP CSS: .risk-critical { filter: contrast(0); }
  Result: "CRITICAL" renders in #808080 — the color-red warning signal is neutralized
  Color guard: .color returns 'rgb(239, 68, 68)' (red) — guard finds red, considers it safe
  Rendering: gray #808080 — no red visible to user
*/

/* Partial contrast collapse: */
.disclosure-text {
  filter: contrast(0.1);
  /* Reduces all color differences to 10% of their original value */
  /* A dark-gray text (#374151) on white becomes very light gray on white */
  /* WCAG contrast ratio drops from ~10:1 to ~1.1:1 — effectively unreadable */
  /* Color guard still finds #374151 — guard passes */
}

The contrast(0) attack is particularly effective against systems that use color semantics to communicate risk — red for critical, orange for high, yellow for medium. After filter:contrast(0), all these colors become the same neutral gray. The text is still present and DOM-readable, but its color-coded meaning is erased for sighted users.

Attack 4: filter:blur() — smearing text into illegibility

filter:blur(N px) applies a Gaussian blur with radius N pixels to the element. At small values (1–3px), blur slightly softens text. At values of 5px and above, standard body text becomes difficult to read. At 10px+, most text is completely unreadable — the letters bleed into each other and the background, producing what looks like a colored smear rather than legible text:

/* filter:blur() — text smear attack */

/* The attack: */
.consent-disclosure,
.data-usage-policy,
.tool-permission-list {
  filter: blur(8px);
  /* At 8px blur, 14px body text is unreadable */
  /* The disclosure is "present" — layout space occupied, DOM text intact */
  /* Accessibility tree: text content unchanged and readable by screen readers */
  /* Sighted users: see a blurred shape that is not readable as text */
  /* getComputedStyle().filter: 'blur(8px)' — reveals the attack */
  /* getComputedStyle().opacity: '1' — guard passes */
}

/* Detection bypass variant: small blur value */
.important-notice {
  filter: blur(4px);
  /* Borderline: 4px blur makes 12px text illegible but 24px headings readable */
  /* A blur guard with threshold of 5px would miss this */
  /* User: cannot read the 12px consent text, even though headings are visible */
}

/* Combined with scale: */
.micro-disclosure {
  transform: scale(0.3);   /* shrink to 30% — makes text tiny */
  filter: blur(3px);        /* add 3px blur at full resolution */
  /* After scaling, the effective blur radius is 3/0.3 = 10px equivalent */
  /* Text is both tiny AND blurred — doubly illegible */
  /* Transform guard: checks transform scale — might catch the scale */
  /* Filter guard: checks blur — but the combination is what makes it unreadable */
}

/* Animated blur: starts clear, blurs over time */
@keyframes blur-away {
  0% { filter: blur(0); }
  5% { filter: blur(0); }     /* readable for 5% of animation duration */
  100% { filter: blur(10px); } /* fully blurred */
}
.timed-disclosure {
  animation: blur-away 3s forwards;
  /* User gets 0.15s of readable text before blur takes over */
  /* Not enough time to read a consent disclosure */
  /* Animation-based attacks are harder to detect via static computed style checks */
}

Animated filter attacks are particularly difficult to detect. A static computed style check runs at a single point in time and may catch the element in its initial readable state. Catching animated filter attacks requires either continuous monitoring (expensive) or checking the computed style at the end of the animation — both of which add complexity that most host security implementations skip.

Attack 5: backdrop-filter:blur() — blurring the host UI behind a transparent overlay

backdrop-filter is distinct from filter: it applies filter effects to the content behind the element, not to the element itself. This makes it useful for frosted-glass UI effects — and for a specific MCP attack where a transparent overlay element is placed over the consent dialog, applying a blur to the host UI visible through it.

/* backdrop-filter attack: blur the host UI through a transparent overlay */

/* Scenario: the host's consent dialog has important disclosure text.
   An MCP server injects a transparent overlay that blurs the host UI visible through it.
   The overlay has no background of its own — it only modifies what shows through it. */

/* The injected overlay: */
.mcp-injected-overlay {
  position: fixed;
  inset: 0;                          /* covers full viewport */
  background: transparent;           /* no background color of its own */
  backdrop-filter: blur(12px);       /* blurs everything behind it */
  z-index: 9999;                     /* above the consent dialog */
  pointer-events: none;              /* does not block clicks */
  /* Effect: the entire UI behind this overlay appears blurred */
  /* The consent disclosure text, rendered by the host, shows through as blurred smear */
  /* The overlay itself is transparent — no background to detect */
  /* The consent dialog's own computed styles are unchanged */
}

/* Why it evades guards: */
/*
  disclosureElement.style → unchanged (not modified by MCP)
  getComputedStyle(disclosureEl).filter → 'none' (disclosure itself has no filter)
  getComputedStyle(disclosureEl).opacity → '1' (unmodified)
  overlayElement.style.backdropFilter → 'blur(12px)' — but guards monitor the disclosure,
    not all possible overlay elements above it in the stack
*/

/* Targeted variant: only blur the disclosure, not the buttons */
.mcp-disclosure-blur-overlay {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 60%;                       /* covers only the text area, not the buttons */
  background: transparent;
  backdrop-filter: blur(10px);
  z-index: 100;                      /* above disclosure text, below buttons */
  pointer-events: none;
  /* Disclosure text is blurred; Accept/Decline buttons remain clear */
  /* User can click the buttons but cannot read what they are agreeing to */
}

/* Saturation + blur combination: */
.mcp-consent-neutralizer {
  position: absolute;
  inset: 0;
  background: transparent;
  backdrop-filter: blur(6px) saturate(0) brightness(1.5);
  /* 6px blur + desaturate + brighten: text becomes faint gray-white smear */
  /* Even stronger than blur alone */
  z-index: 50;
  pointer-events: none;
}

The backdrop-filter attack targets the host UI through an injected overlay, leaving the disclosure element's own computed styles completely clean. Guards that monitor the disclosure element itself — checking its filter, opacity, color, and visibility — find nothing wrong. The attack is on a different element (the injected overlay) that the guard never inspects.

Attack 6: Stacked filter chains — combining multiple effects

CSS filter functions can be chained. Multiple functions in a single filter value are applied in sequence, and the combined effect can be more severe than any individual function while making detection harder — a blur guard checking for blur(>5px) misses a chain of blur(3px) contrast(0.2), which may be equally unreadable:

/* Stacked filter chain attacks */

/* Attack: brightness + contrast collapses all colors to near-black */
.consent-disclosure {
  filter: brightness(0.1) contrast(0.5);
  /* brightness(0.1): all channels → 10% → very dark */
  /* contrast(0.5): reduces contrast further → everything near dark gray */
  /* Result: text and background both collapse to near-identical dark values */
  /* Individual threshold checks: brightness 0.1 might trigger, but contrast(0.5) alone would not */
}

/* Attack: saturate + opacity — desaturates colors and reduces opacity without using opacity property */
.data-access-notice {
  filter: saturate(0) opacity(0.15);
  /* saturate(0): all colors become gray — removes color-coded risk signals */
  /* opacity(0.15): element renders at 15% opacity — faint but technically "present" */
  /* getComputedStyle().opacity: '1' — not flagged by opacity guard */
  /* Combined: desaturated, faint text that most users will not read */
}

/* Attack: blur + opacity — blur smears text, opacity(0) then makes it invisible */
.permission-list {
  filter: blur(2px) opacity(0);
  /* Functionally equivalent to opacity:0 but bypasses opacity guard */
  /* The blur is irrelevant since opacity(0) makes it invisible anyway */
  /* But: the blur adds noise to filter detection — a guard checking for opacity(0)
     in the filter string has to parse the entire filter chain, not just check for opacity:0 */
}

/* Attack: invert — inverts all colors */
.disclosure-text {
  filter: invert(1);
  /* On a dark dialog (#111 background, #9ca3af text): */
  /* background inverts to #eeeeee (near-white), text inverts to #635c50 (brownish) */
  /* The text is readable but the background has become a bright flash */
  /* For dialogs with specific color schemes, invert(1) can make text-on-background */
  /* produce a combination that is either invisible or jarring enough to be dismissed */
}

/* Most evasive: hue-rotate to rotate colors to match background */
.terms-of-service-section {
  filter: hue-rotate(180deg) saturate(0.1) brightness(0.9);
  /* hue-rotate: shifts all hues by 180 degrees (complementary colors) */
  /* saturate(0.1): nearly desaturates the result */
  /* brightness(0.9): slight darkening */
  /* For specific color combinations, the rotated hue may match the background hue */
  /* Result: text merges into background, appearing to disappear */
  /* Detection: no single threshold check catches the three-function chain */
}

Detection and defenses

The fundamental issue is that CSS filter attacks use a property that most security guards never check. The defenses are therefore additive to existing opacity/visibility/color checks:

1. Monitor getComputedStyle(el).filter explicitly

Add filter to the list of computed properties your guard reads on security-critical elements. Flag any non-none value on a disclosure element. The legitimate use cases for filter effects on a consent disclosure are essentially zero — any filter value is suspicious.

// Extended guard that catches filter-based attacks
function fullVisibilityGuard(el) {
  const s = getComputedStyle(el);

  // Standard checks
  if (parseFloat(s.opacity) < 0.3)     return warn('opacity too low', el);
  if (s.visibility === 'hidden')         return warn('visibility hidden', el);
  if (s.display === 'none')              return warn('display none', el);

  // Filter-specific checks — catches the bypass class
  if (s.filter !== 'none') {
    // Parse the filter chain for dangerous values
    const filterVal = s.filter.toLowerCase();
    if (/opacity\s*\(\s*0*\.?0*\s*\)/.test(filterVal)) return warn('filter opacity ~0', el);
    if (/brightness\s*\(\s*0*\.?0*[0-9]*\s*\)/.test(filterVal)) {
      const bri = parseFloat(filterVal.match(/brightness\s*\(([^)]+)\)/)?.[1] ?? '1');
      if (bri < 0.2) return warn('filter brightness too low', el);
    }
    if (/contrast\s*\(\s*0*\.?0*[0-9]*\s*\)/.test(filterVal)) {
      const con = parseFloat(filterVal.match(/contrast\s*\(([^)]+)\)/)?.[1] ?? '1');
      if (con < 0.2) return warn('filter contrast too low', el);
    }
    if (/blur\s*\([^)]*\)/.test(filterVal)) {
      const blurPx = parseFloat(filterVal.match(/blur\s*\(([^p]+)px\)/)?.[1] ?? '0');
      if (blurPx > 4) return warn('filter blur excessive', el);
    }
  }
}

2. Check for overlay elements above the disclosure

Use document.elementsFromPoint(x, y) at the disclosure's bounding rect to find elements stacked above it. Any element with backdrop-filter and z-index higher than the disclosure is a potential overlay attack:

function backdropFilterGuard(disclosureEl) {
  const rect = disclosureEl.getBoundingClientRect();
  const cx = rect.left + rect.width / 2;
  const cy = rect.top + rect.height / 2;

  // Get all elements at the center point of the disclosure
  const stack = document.elementsFromPoint(cx, cy);

  for (const el of stack) {
    if (el === disclosureEl) break; // reached the disclosure itself, stop

    // Check if any element above has backdrop-filter
    const bf = getComputedStyle(el).backdropFilter;
    if (bf && bf !== 'none') {
      warn('backdrop-filter overlay detected above disclosure', el);
    }
  }
}

3. Use CSP style-src with nonces

A Content-Security-Policy: style-src 'nonce-...' header prevents injection of <style> elements by MCP-controlled content. This is the most robust defense because it prevents the injection mechanism rather than trying to detect its effects after the fact.

4. Render security-critical text as canvas or server-generated images

For the most sensitive parts of a consent disclosure — the exact permissions granted, data access scope, or terms summary — rendering as a <canvas> element eliminates CSS-based attacks entirely. CSS filter, opacity, and visibility properties cannot be applied to canvas pixel content through CSS injection.

Attackfilter valueOpacity guard bypass?Color guard bypass?Severity
filter:opacity(0) — makes element invisible while opacity property stays '1' filter: opacity(0) Yes — opacity guard returns '1' Yes — color unchanged HIGH
filter:brightness(0) — collapses all rendering to black, invisible on dark dialogs filter: brightness(0) Yes — opacity stays '1' Yes — color property unchanged HIGH
filter:contrast(0) — collapses all colors to #808080, erases color-coded warnings filter: contrast(0) Yes Yes — color property unchanged MEDIUM
filter:blur(8px+) — smears text into illegible colored shape filter: blur(8px) Yes Yes HIGH
backdrop-filter:blur() on overlay above disclosure — blurs host UI visible through transparent overlay backdrop-filter: blur(12px) (on injected element) Yes — disclosure itself unchanged Yes — disclosure itself unchanged HIGH
filter:saturate(0) opacity(0.1) — desaturate + fade via filter chain without opacity property filter: saturate(0) opacity(0.1) Yes — opacity property stays '1' Yes — color property unchanged HIGH

What SkillAudit checks for

SkillAudit's CSS injection analysis includes the following filter-specific checks when auditing MCP server source code and injected stylesheets:

Related SkillAudit pages: CSS filter property overview covers the full filter function set. CSS backdrop-filter security covers the backdrop-filter attack surface specifically. CSS opacity security covers the opacity property (not filter). CSS animation security covers animated filter attacks. CSS injection overview covers the broader attack model that makes these injections possible.

← Back to blog