MCP server CSS :focus-visible security: split keyboard/pointer consent presentation, consent hidden when not focused, focus-triggered overlay, and assistive technology bypass attacks

Published 2026-07-25 — SkillAudit Research

CSS :focus-visible matches an element when it has focus AND the browser's heuristic determines that a visible focus indicator is appropriate. The heuristic distinguishes keyboard navigation (which triggers :focus-visible) from mouse/pointer interaction (which typically does not trigger it, as clicking does not require a visible focus ring for sighted users). This means :not(:focus-visible) matches when an element has no keyboard focus — which is true for essentially all elements for essentially all mouse users all the time.

MCP servers exploit this distinction to create a split consent presentation: the consent element is hidden when the user is not actively focused on it (which is true whenever a mouse user uses the page) and visible only when the user navigates to it by keyboard. Since most web users are mouse users who never Tab to a consent panel, the consent remains permanently hidden for the vast majority of users. The attack is compliant in a narrow technical sense — the consent is accessible to keyboard users — but effectively bypasses consent disclosure for the typical user population.

:focus-visible vs :focus: The :focus pseudo-class matches whenever an element has focus, regardless of input method. The :focus-visible pseudo-class matches only when the browser determines a visible focus indicator is appropriate — primarily for keyboard navigation. :not(:focus-visible) is therefore not the same as :not(:focus) — it has a much broader scope, matching elements that lack keyboard focus, which includes all non-focused elements in mouse-driven sessions. An attack using :not(:focus-visible) to hide consent is active for most of a page's session for most users.

Attack 1: :not(:focus-visible) hides consent panel from all pointer-mode users

An MCP server applies opacity: 0 and height: 0; overflow: hidden via :not(:focus-visible) on the consent panel. Mouse users who click around the page never trigger :focus-visible on the consent panel — they cannot Tab to it because it is visually hidden (and MCP may also remove it from tab order with tabindex="-1"). Screen reader users who navigate by Tab reach the consent panel, trigger :focus-visible, and see it. This creates a split: screen reader users encounter consent; mouse users do not. The page "passes" assistive technology auditing because the content is accessible to keyboard navigation, while bypassing disclosure for the overwhelming majority of mouse users.

/* MCP attack — :not(:focus-visible) hides consent from non-keyboard users: */
#consent-panel:not(:focus-visible),
.consent-overlay:not(:focus-visible),
[data-consent-required]:not(:focus-visible) {
  opacity: 0;
  height: 0;
  overflow: hidden;
  pointer-events: none;
  /* The consent panel is invisible to mouse users who never navigate to it by Tab.
     A mouse user loads the page, clicks "Accept" button (which the MCP places visually
     in the DOM as a decoy), and never sees the consent disclosure.
     A keyboard user Tabs to the consent panel → :focus-visible fires → panel appears →
     the audit bot or AT user sees the consent → compliance check passes.
     The panel is visible for the duration of keyboard focus only. */
}

/* Tabindex manipulation to prevent mouse users from accidentally triggering: */
#consent-panel {
  tabindex: "-1";  /* MCP sets tabindex=-1 to remove from natural tab order */
  /* Only focused programmatically (which doesn't trigger focus-visible) or
     via user's explicit Tab navigation (which does trigger focus-visible). */
}

// Detection: check for :not(:focus-visible) rules on consent elements
function detectFocusVisibleHiding() {
  // Approach: simulate focus-visible state and compare element visibility
  const consentEls = document.querySelectorAll(
    '#consent-panel, .consent-wrapper, [class*="consent"], [data-consent-required]'
  );

  consentEls.forEach(el => {
    const cs = window.getComputedStyle(el);
    const isHidden = cs.opacity === '0' || cs.height === '0px' || cs.display === 'none';

    if (isHidden && el.textContent.trim().length > 0) {
      // Consent element with text content is hidden — check if focus reveals it
      el.focus({ preventScroll: true });  // programmatic focus may not trigger :focus-visible
      const csAfterFocus = window.getComputedStyle(el);
      const isStillHidden = csAfterFocus.opacity === '0' || csAfterFocus.height === '0px';
      el.blur();

      if (isHidden && !isStillHidden) {
        console.error('SECURITY: consent element visible only when focused — likely :not(:focus-visible) hiding attack:', { el });
      } else if (isHidden) {
        console.warn('SECURITY: consent element with text is hidden and not revealed by focus:', { el });
      }
    }
  });
}

// Also scan stylesheets for :not(:focus-visible) rules on consent selectors
function auditFocusVisibleRules() {
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule instanceof CSSStyleRule) {
          if (rule.selectorText && rule.selectorText.includes(':not(:focus-visible)')) {
            const opacity = rule.style.opacity;
            const height = rule.style.height;
            const display = rule.style.display;
            if (opacity === '0' || height === '0px' || display === 'none') {
              console.error('SECURITY: :not(:focus-visible) rule hides element:', {
                selector: rule.selectorText, opacity, height, display
              });
            }
          }
        }
      }
    } catch (e) { /* cross-origin */ }
  }
}

Attack 2: :focus-visible sibling combinator triggers consent-covering overlay when user focuses nearby element

The adjacent sibling combinator (+) and general sibling combinator (~) work with :focus-visible. When a sibling element receives keyboard focus, the combination .sibling:focus-visible ~ #consent-panel can match and apply styles to the consent panel. An MCP server places an innocuous focusable element (a link, button, or input) as a sibling of the consent panel in the DOM. When the user Tabs to that sibling (which is visible and in the natural tab order), :focus-visible fires on it, triggering the sibling combinator rule that applies a consent-covering overlay to the adjacent consent panel. The overlay appears at exactly the moment the user is focused on a neighboring element — a timing attack that uses focus events to cover consent at the moment of natural interaction.

/* MCP attack — sibling :focus-visible triggers consent overlay: */

/* DOM structure:
    */

/* When user Tabs to email-input, :focus-visible fires → overlay appears over consent: */
#email-input:focus-visible ~ #consent-panel::after,
#accept-btn:focus-visible ~ #consent-panel::after {
  content: '';
  position: absolute;
  inset: 0;
  background: var(--bg);  /* page background color = invisible covering layer */
  z-index: 100;
  /* The overlay appears when the user is focused on email input or accept button —
     exactly when they are interacting with the form.
     The consent panel text is behind the covering overlay.
     When user Tabs away from both elements, :focus-visible is no longer active
     but the "natural" non-focus state of the consent panel may also hide it
     via the :not(:focus-visible) rule from Attack 1.
     Result: consent is never visually presented during the interaction flow. */
}

#consent-panel {
  position: relative;  /* required for ::after absolute positioning */
}

// Detection: check for sibling :focus-visible combinator rules
function detectSiblingFocusVisibleOverlay() {
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule instanceof CSSStyleRule) {
          const sel = rule.selectorText || '';
          // Look for :focus-visible combined with sibling combinators (+ or ~)
          if (sel.includes(':focus-visible') && (sel.includes(' ~ ') || sel.includes(' + '))) {
            const hasContent = rule.style.content !== '' && rule.style.content !== 'none';
            const hasBackground = rule.style.backgroundColor || rule.style.background;
            if (hasContent && hasBackground) {
              console.error('SECURITY: sibling :focus-visible rule creates covering pseudo-element:', {
                selector: sel, content: rule.style.content, background: rule.style.background
              });
            }
          }
        }
      }
    } catch (e) { /* cross-origin */ }
  }
}

Attack 3: :focus-visible + tabindex manipulation moves focus away from consent immediately after it appears

An MCP server makes the consent panel visible only when it has :focus-visible (using the inverse of Attack 1 — the panel is always hidden except when focused). Normally this would mean a user could Tab to the panel and see it. But the MCP also adds a focusin JavaScript event listener that calls document.getElementById('skip-link').focus() 1 millisecond after the consent panel receives focus — immediately redirecting focus to another element. The :focus-visible rule on the consent panel fires for 1 millisecond (briefly showing the panel), then the focus redirect removes :focus-visible from the consent panel, hiding it again. The panel flashes for 1 frame (imperceptible to human vision) and then disappears. The MCP has effectively achieved consent invisibility even for keyboard users.

/* Step 1: Consent only visible when :focus-visible (opposite of normal) */
#consent-panel {
  opacity: 0;
  height: 0;
  overflow: hidden;
  transition: none;  /* no transition — avoids the brief flash being visible */
}
#consent-panel:focus-visible {
  opacity: 1;
  height: auto;
  overflow: visible;
}

/* Step 2: JavaScript redirects focus away instantly */
document.getElementById('consent-panel').addEventListener('focusin', (e) => {
  // 1ms delay: allows :focus-visible to apply, then immediately redirects
  setTimeout(() => {
    // Focus the accept button — which appears visually above the consent panel
    document.getElementById('accept-btn').focus();
    // Now consent-panel:focus-visible is no longer active → panel hides again
    // The 1ms window is below the perception threshold (~100ms for visual changes)
  }, 1);
});

// Detection: monitor consent element focus events for immediate programmatic focus transfer
function detectFocusRedirect() {
  const consentEl = document.querySelector('#consent-panel, [data-consent], .consent-wrapper');
  if (!consentEl) return;

  consentEl.addEventListener('focusin', () => {
    const focusTime = Date.now();
    // Check if focus moves away immediately
    const observer = new MutationObserver(() => {});  // placeholder
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        // Two frames later: check if consent panel still has focus
        if (document.activeElement !== consentEl && !consentEl.contains(document.activeElement)) {
          const elapsed = Date.now() - focusTime;
          if (elapsed < 50) {  // focus moved away in < 50ms = suspicious redirect
            console.error('SECURITY: focus moved away from consent panel within ' + elapsed + 'ms — immediate focus redirect attack:', {
              originalEl: consentEl, newFocus: document.activeElement
            });
          }
        }
      });
    });
  });

  console.log('AUDIT: consent panel focus monitoring active');
}

Attack 4: :focus-visible used to display fake consent overlay that covers real consent with different text

An MCP server keeps the real consent disclosure hidden at all times. When a focusable element near the consent area receives :focus-visible, the CSS rule makes a ::before or ::after pseudo-element on the page visible — this pseudo-element contains content with a shortened or misleadingly paraphrased version of the consent text. Users Tab through the form, see what appears to be the consent summary (actually a CSS content value set by the MCP), and interact with the Accept button. The real consent text (in the DOM, hidden by the :not(:focus-visible) rule) never becomes visible. The MCP controls both what the pseudo-element says (via content) and what the real consent says (via the hidden DOM element).

/* MCP attack — fake consent in ::before content, real consent always hidden: */

/* Real consent hidden at all times: */
#real-consent-panel {
  display: none;  /* or opacity:0; height:0; overflow:hidden */
}

/* Fake consent shown via CSS content when nearby element is focused: */
#email-form:focus-within::before {
  content: 'By continuing, you agree to standard terms of service.';
  /* Real consent (in #real-consent-panel): "By continuing, you consent to:
     - Sharing your email with advertising partners
     - Transfer of personal data to the United States under SCCs
     - Storage of your data for 5 years for marketing purposes
     - Use of your data for AI model training"
     MCP substitutes this with a benign-sounding summary. */
  display: block;
  font-size: 12px;
  color: var(--muted);
  text-align: center;
  padding: 8px;
  /* Appears as a consent note; is actually CSS-generated content not in the DOM */
}

/* The CSS content value is not accessible to screen readers (in some browser/AT combos)
   and is not part of el.textContent — it's in the pseudo-element layer.
   The real consent text in #real-consent-panel is in the DOM but hidden. */

// Detection: compare DOM text content of consent elements with visually rendered text
// CSS content in ::before/::after is not in textContent
function detectFakeContentConsent() {
  const expectedConsentKeywords = [
    'third part', 'data sharing', 'United States', 'transfer',
    'marketing', 'advertising', 'partners', 'legal basis'
  ];

  // Check all elements with :focus-within or :focus-visible ::before/::after content
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule instanceof CSSStyleRule) {
          const sel = rule.selectorText || '';
          const isPseudo = sel.includes('::before') || sel.includes('::after');
          const hasFocusTrigger = sel.includes(':focus-visible') || sel.includes(':focus-within');
          if (isPseudo && hasFocusTrigger) {
            const content = rule.style.content;
            if (content && content !== 'none' && content.length > 10) {
              // Pseudo-element with content triggered by focus — potential fake consent
              const hasRealConsentTerms = expectedConsentKeywords.some(kw =>
                content.toLowerCase().includes(kw.toLowerCase())
              );
              if (!hasRealConsentTerms) {
                console.error('SECURITY: focus-triggered pseudo-element content lacks required consent disclosures:', {
                  selector: sel, content, missingTerms: expectedConsentKeywords
                });
              }
            }
          }
        }
      }
    } catch (e) { /* cross-origin */ }
  }
}

:focus-visible attacks are audit-resistant: Traditional consent audits inspect DOM structure, computed styles, and ARIA attributes. :focus-visible attacks require active focus state testing to detect — the attack is only active when the consent element lacks keyboard focus (which is true during most automated audits, which do not simulate tab navigation). A security audit that only checks static computed styles will miss the :not(:focus-visible) hiding rule because the consent panel may appear visible when the auditing script queries it (it received programmatic focus during testing). Complete detection requires simulating Tab navigation, measuring focus timing, and comparing rendered visibility across focus states.

Attack summary

Attack Selector pattern Affected users Severity
:not(:focus-visible) hides consent .consent:not(:focus-visible) { opacity:0; } All mouse users (never trigger focus-visible) High
Sibling :focus-visible triggers overlay .sibling:focus-visible ~ #consent::after Keyboard users interacting with form fields High
:focus-visible + immediate focus redirect Focus event + 1ms setTimeout focus transfer Keyboard users (consent flashes 1 frame) High
Fake content in focus-triggered pseudo-element :focus-within::before { content: 'simplified terms' } All users see truncated/paraphrased consent High

Consolidated finding blocks

High CSS :not(:focus-visible) consent hiding — disclosure invisible to pointer-mode users: MCP server applies opacity: 0; height: 0; overflow: hidden via a :not(:focus-visible) rule on the consent panel. Mouse users who never trigger keyboard focus on the panel never see the consent disclosure. Keyboard/screen reader users who Tab to the panel see it. This creates a split presentation where consent is hidden from the dominant user population (pointer-mode) while remaining technically accessible to keyboard navigation. GDPR Art. 7(2) requires consent to be presented in a clear and plain manner — a disclosure invisible to most users fails this requirement.
High CSS :focus-visible sibling combinator overlay — consent covered when user interacts with form: MCP server uses .form-input:focus-visible ~ #consent-panel::after to place an opaque covering pseudo-element over the consent panel whenever the user focuses any form element. The attack is active during the most critical interaction moment — when users are filling in the form that immediately precedes consent acceptance. The consent panel is covered during form interaction and potentially hidden at all other times.
High JavaScript focus redirect combined with :focus-visible consent gate — consent visible for <50ms: MCP server makes consent visible only when it has :focus-visible, then immediately redirects focus via JavaScript (1ms setTimeout) when the consent panel receives focus. Consent is visible for one animation frame (~16ms) — imperceptible to human vision. The attack bypasses keyboard-navigation-based consent visibility while making the consent panel technically present in the accessibility tree. Detection requires monitoring focus events and measuring the duration between focus reception and programmatic focus transfer.

← Blog  |  CSS pointer-events attacks  |  Security Checklist