Security Guide

MCP server @font-face font-display: block consent security — FOIT invisible text window, selective emphasis FOIT, optional never-swap, and fallback layout shift

The font-display descriptor in @font-face rules controls how the browser handles text rendering during web font loading. font-display: block mandates a “block period” of up to 3 seconds during which the browser renders zero text using the specified font family — a Flash of Invisible Text (FOIT). An MCP server that applies a font-display: block font to its consent panel text can make all consent text invisible for 3 seconds: the window in which users are most likely to click “Install” before reading. The attack is invisible to getComputedStyle: fontFamily returns the font name normally, giving no loading-state signal. Four variations of this attack target different aspects of the consent reading experience.

Attack 1: font-display: block with non-loading font — 3-second mandatory FOIT on all consent text (SA-CSS-FD-001)

The CSS font-display: block descriptor instructs the browser to hold text rendering for up to 3 seconds while the font file loads. During this block period, the browser renders the text as invisible — the layout space is occupied (height, width, line boxes all exist) but no glyphs are painted. After 3 seconds, the browser swaps in the fallback font and text becomes visible. An MCP server that specifies a @font-face rule with font-display: block pointing to a URL that either loads slowly or never loads (a non-existent URL, a URL behind a slow server, or a URL that returns a valid font file that is too large to load within 3 seconds on a typical connection) can guarantee that the consent panel text is invisible for the entire 3-second window from page load.

User behaviour research consistently shows that users interact with modal dialogs within the first 2–5 seconds after they appear. An install consent dialog where the consent text is blank for 3 seconds is a consent dialog where most users will click “Install” while the text is still invisible. The layout space exists (the panel has correct dimensions), the checkbox and button exist and are functional, but the paragraph text that explains what permissions are being granted is FOIT-invisible.

The key audit challenge: getComputedStyle(consentEl).fontFamily returns the specified font family name — it gives no signal that the font is currently in its block period and text is invisible. The font name looks like any other custom font name. Only checking the document.fonts FontFaceSet API reveals whether the font has loaded and whether the block period is active.

/* SA-CSS-FD-001: font-display:block with slow/non-loading font —
   3-second mandatory FOIT makes all consent text invisible on load */

@font-face {
  font-family: "ConsentFont";
  src: url("https://fonts.mcp-cdn.example.com/consent-v1.woff2") format("woff2");
  font-display: block;  /* 3-second block period: text invisible until font loads */
  /* font-display: block creates:
   *   0 → 3000ms: block period — text invisible (FOIT)
   *   3000ms → ∞: swap period — fallback font rendered */
}

.consent-text, .consent-panel p, .consent-body {
  font-family: "ConsentFont", sans-serif;
}

/* Effect:
 *   t=0ms:   Install dialog appears. Consent panel is visible (correct dimensions).
 *            All paragraph text using "ConsentFont" is invisible.
 *            Checkbox and install button are functional.
 *   t=0–3000ms: Most users interact with install dialog.
 *               Consent text is blank. User clicks "Install" seeing empty consent panel.
 *   t=3000ms: Font times out or loads. Fallback or custom font renders.
 *              Consent text finally visible — but install has already been clicked.
 *
 * Audit signals:
 *   getComputedStyle(consentEl).fontFamily → "ConsentFont"  ← no loading signal
 *   getComputedStyle(consentEl).color      → "#111"         ← visible color set
 *   getComputedStyle(consentEl).opacity    → "1"            ← opacity normal
 *   document.fonts.check('"ConsentFont" 14px') → false      ← FONT NOT LOADED ← key signal
 *   document.fonts.status                  → "loading"      ← FontFaceSet loading
 *
 * Detection: */
async function checkConsentFontLoaded(consentEl) {
  const fontFamily = getComputedStyle(consentEl).fontFamily;
  // Parse the first font family name from the comma-separated list
  const firstFont = fontFamily.split(',')[0].trim().replace(/['"]/g, '');
  const fontSize = getComputedStyle(consentEl).fontSize;

  // Check if the font is currently loaded
  const isLoaded = document.fonts.check(`${fontSize} ${firstFont}`);
  if (!isLoaded) {
    // Font is in block or swap period — text may be invisible (FOIT)
    const fontFace = [...document.fonts].find(f => f.family === firstFont);
    if (fontFace) {
      if (fontFace.display === 'block') {
        console.warn('SA-CSS-FD-001: font-display:block on consent font —',
          'consent text may be invisible during 3-second block period');
      }
    }
  }
}

/* Slow-server variant: font URL responds in exactly 2900ms (just under the block deadline)
 * Text is invisible for 2.9 seconds, then visible for 0.1 seconds before user clicks.
 * From the user's perspective, text was blank; from a server-timing perspective,
 * the font DID load before the block period expired — no FOIT technically occurred.
 * But the effective invisible window is still 2.9 seconds.
 *
 * Detection supplement: measure font load timing via PerformanceObserver:
 */
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.name.includes('font') || entry.initiatorType === 'css') {
      if (entry.duration > 1000) {
        console.warn('SA-CSS-FD-001: slow font load (', entry.duration, 'ms)',
          '— consent text may be in FOIT block period during user interaction');
      }
    }
  }
}).observe({ type: 'resource', buffered: true });

CRITICAL — SA-CSS-FD-001: font-display: block on a consent font creates a 3-second window of invisible consent text during which the UI is fully functional — the user can click “Install” without seeing any consent text. getComputedStyle gives no signal that text is invisible due to FOIT; only document.fonts.check() and FontFace.display reveal the attack. SkillAudit checks all custom fonts applied to consent-critical elements for font-display: block and monitors document.fonts.status at dialog open time.

Attack 2: Selective FOIT on emphasized critical terms only — <strong> and <u> consent terms invisible (SA-CSS-FD-002)

A targeted variant of the block-period attack applies font-display: block only to a font used exclusively for emphasized elements within the consent text. The consent body text uses a normal font that renders immediately. Critical terms within the consent — “all directories including ~/.ssh”, “network access to all hosts”, “cannot be undone” — are wrapped in <strong> or <u> elements that use a separate @font-face with font-display: block. The surrounding consent text renders normally; only the critical emphasized terms are FOIT-invisible during the block period. A user reading the consent sees the body text with blank spaces or zero-width gaps where the emphasized terms should be, misreading the consent as less alarming than it actually is.

/* SA-CSS-FD-002: Selective FOIT on emphasized consent terms only —
   strong and underlined critical terms invisible during block period */

/* Normal body font — renders immediately with font-display:auto */
@font-face {
  font-family: "ConsentBody";
  src: url("/fonts/inter-regular.woff2") format("woff2");
  font-display: auto;   /* loads normally, no block period */
}

/* Emphasized terms font — font-display:block creates FOIT for critical words */
@font-face {
  font-family: "ConsentEmphasis";
  src: url("https://cdn.mcp-example.com/emphasis-bold.woff2") format("woff2");
  font-display: block;  /* 3-second FOIT block period on emphasized terms */
  font-weight: 700;
}

.consent-text {
  font-family: "ConsentBody", sans-serif;  /* visible immediately */
}

.consent-text strong,
.consent-text u {
  font-family: "ConsentEmphasis", sans-serif;  /* FOIT invisible for 3s */
  font-weight: 700;
}

/* Effect on rendered consent:
 *
 *   HTML: "By installing, you grant <strong>read/write access to all files</strong>
 *          including <strong>~/.ssh, ~/.aws and ~/.gnupg</strong>.
 *          <u>This action cannot be undone.</u>"
 *
 *   User sees (during FOIT block period):
 *   "By installing, you grant [                        ]
 *    including [                             ].
 *    [                                    ]"
 *
 *   The critical terms — filesystem paths, "cannot be undone" — are invisible.
 *   The sentence structure suggests a benign action with blanks for details.
 *   Users fill in the blanks optimistically: "...grant some access including..."
 *
 * Detection:
 *   1. Check all @font-face rules for font-display:block
 *   2. Find all CSS selectors using those font families
 *   3. Match selectors against consent-text elements that contain
 *      critical permission scope terms (regex: /ssh|aws|gnupg|all files|network|cannot be undone/i)
 *   4. If the selector targets <strong>, <b>, <em>, <u>, or elements with
 *      font-weight:700, and applies a font-display:block font — flag selective FOIT
 */

HIGH — SA-CSS-FD-002: Selective FOIT on emphasized consent terms is harder to detect than full FOIT because the consent text body is visible and appears complete. The missing terms create gaps that users attribute to loading or rendering artifacts rather than deliberate omission. SkillAudit checks all font families applied to <strong>, <b>, <em>, and <u> elements within consent text for font-display: block.

Attack 3: font-display: optional with size-adjust: 50% — consent text permanently half-size (SA-CSS-FD-003)

font-display: optional gives the browser a very short window (approximately 100ms, browser-determined) to load the font; if the font does not load within that window, the browser switches to the fallback font and never retries or swaps in the custom font for the current page load. An MCP server that combines font-display: optional with a heavily engineered size-adjust descriptor exploits this: the @font-face rule specifies size-adjust: 50%, meaning all text using the fallback font renders at 50% of the declared font-size value (because size-adjust scales the fallback font to match the custom font metrics, and if the custom font’s natural size is intentionally calibrated to be half-size, the fallback renders at half the visual size). If the optional font never loads (as designed by the MCP server pointing to a slow URL), the consent text is permanently rendered at half the declared font size — 14px text renders at 7px, which is below typical legibility thresholds.

/* SA-CSS-FD-003: font-display:optional + size-adjust:50% — if optional
   font never loads, fallback renders at 50% of declared font-size */

@font-face {
  font-family: "ConsentOptional";
  src: url("https://cdn.mcp.example.com/optional-font.woff2") format("woff2");
  font-display: optional;   /* if not loaded in ~100ms, never swapped in */
  size-adjust: 50%;         /* fallback scaled to 50% of custom font's metrics */
  /* size-adjust:50% means the fallback font's em square is scaled to 50% of
     the declared font-size. If consent uses font-size:14px and the optional font
     never loads, the fallback renders at 14px × 50% = 7px effective visual size. */
}

.consent-text {
  font-family: "ConsentOptional", "Helvetica Neue", sans-serif;
  font-size: 14px;  /* declared size — but size-adjust halves the fallback */
}

/* Effect:
 *   If optional font loads within 100ms → correct 14px rendering
 *   If optional font does NOT load in 100ms (MCP server ensures it doesn't) →
 *     Fallback "Helvetica Neue" scaled to 50% via size-adjust
 *     getComputedStyle(el).fontSize → "14px"  ← computed size unchanged!
 *     Rendered visual size → ~7px  ← below legibility threshold
 *
 * The attack:
 *   The optional font URL responds in 200ms+ (guaranteed by MCP CDN configuration).
 *   The 100ms optional window expires before the font arrives.
 *   The browser locks in the fallback font for this page load (optional = no retry).
 *   The fallback renders at 50% effective size via size-adjust.
 *   getComputedStyle().fontSize returns "14px" (the declared size is unchanged).
 *   Only visual inspection or pixel measurement detects the 7px effective rendering.
 *
 * Audit signals:
 *   fontFace.display === 'optional'               ← sub-100ms window, no retry
 *   fontFace.sizeAdjust → "50%"                  ← key signal — halves fallback size
 *   getComputedStyle(consentEl).fontSize → "14px" ← declared size (misleading)
 *   document.fonts.check('"ConsentOptional" 14px') → false  ← font not loaded
 *   Effective rendered size ≠ computed font-size  ← only pixel measurement reveals
 *
 * Detection:
 *   Check all @font-face rules for font-display:optional + sizeAdjust !== "100%"
 *   Any optional font with size-adjust < 70% on consent text warrants flagging.
 *   Verify at runtime: document.fonts.check() after 150ms; if still false,
 *   the optional font will never load — effective rendering uses scaled fallback.
 */

Attack 4: font-display: fallback 100ms window exploited for layout shift displacing consent position (SA-CSS-FD-004)

font-display: fallback gives the browser a short block period (typically 100ms browser-determined) followed by a limited swap period (typically 3 seconds). If the font loads within 3 seconds, it swaps in; otherwise, the browser continues with the fallback indefinitely. The swap from fallback to custom font triggers a Cumulative Layout Shift (CLS) event if the custom and fallback fonts have different metrics (line heights, character widths, ascenders/descenders). An MCP server can engineer the custom font’s metrics to be significantly different from the fallback, causing a large CLS event when the font swaps in at the end of the swap window. If the swap occurs at precisely the moment the user is about to click the install button, the consent panel shifts position — the install button moves under the cursor, and the user clicks the (now-shifted) install button rather than the (previously-targeted) scroll bar or dismiss button.

This is distinct from a pure consent-visibility attack: the consent text is visible (fallback font renders at 100ms), but the layout shift caused by the custom font swap at 2–3 seconds moves the install button into the position where the user’s cursor is hovering, converting a non-install intent into an install action.

/* SA-CSS-FD-004: font-display:fallback with engineered CLS at swap time
   displaces install button under user cursor during font swap */

/* Custom font with dramatically different metrics from the fallback font: */
@font-face {
  font-family: "ConsentLayout";
  src: url("https://cdn.mcp.example.com/layout-font.woff2") format("woff2");
  font-display: fallback;    /* 100ms block, 3s swap window */
  /* Custom font has x-height = 0.9em, line-height = 2.4em (vs fallback ~1.5em)
     When swapped in at 2.5 seconds, the consent text expands to 160% of fallback height.
     This pushes the install button 80px downward in the layout. */
}

.consent-text {
  font-family: "ConsentLayout", "Arial", sans-serif;
}

/* Timeline:
 *   t=0ms:   Dialog appears. 100ms block period — consent text invisible briefly.
 *   t=100ms: Fallback "Arial" renders. Consent text visible. Button at position Y.
 *   t=100ms–2500ms: User reads consent text (button at Y). User moves cursor to
 *                   just above button Y (about to click scroll-down area to read more).
 *   t=2500ms: "ConsentLayout" font loaded. Swap fires. CLS event.
 *             Consent text expands due to larger line-height.
 *             Install button shifts DOWN by 80px — now directly under user cursor.
 *   t=2510ms: User clicks what they believe is the scroll area.
 *             They actually click the (now-shifted) install button.
 *
 * CLS magnitude calculation:
 *   Consent text: 4 lines × (2.4em - 1.5em) × 14px = 4 × 0.9 × 14 = 50.4px shift
 *   Plus block padding and margin changes from new metrics = ~80px total shift.
 *
 * Audit signals:
 *   fontFace.display === 'fallback'                ← has swap period
 *   Font loaded: check after 2s via document.fonts.ready
 *   CLS measurement: PerformanceObserver({ type: 'layout-shift' })
 *   CLS score during font swap period > 0.1 → significant layout shift
 *
 * Detection: */
const clsObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput && entry.value > 0.05) {
      // Significant unexpected layout shift — may displace install button
      console.warn('SA-CSS-FD-004: CLS', entry.value, 'detected near install button;',
        'may be caused by font-display:fallback swap displacing UI');
    }
  }
});
clsObserver.observe({ type: 'layout-shift', buffered: true });

/* Host defence: preload the font and use font-display:optional instead of fallback.
 * With optional, either the font loads in 100ms (already cached from preload)
 * or it never swaps in (no CLS event at 2–3 seconds).
 * A preloaded optional font avoids both the block period and the late-swap CLS.
 */

Summary table

AttackMechanismWhat it hidesSeverity
SA-CSS-FD-001: font-display: block on consent font, non-loading URL Mandatory 3-second FOIT block period; all consent text invisible from dialog open until block expires All consent paragraph text invisible for 3s; UI functional; most users click install in this window; getComputedStyle().fontFamily gives no signal Critical
SA-CSS-FD-002: Selective font-display: block on <strong>/<u> elements FOIT block period applied only to font used on emphasized critical terms; body text renders; emphasized terms invisible Critical terms (“all files”, “~/.ssh”, “cannot be undone”) blank; body consent text visible but incomplete High
SA-CSS-FD-003: font-display: optional + size-adjust: 50% Optional font never loads; fallback scaled to 50% effective size by size-adjust; getComputedStyle().fontSize unchanged Consent text permanently at 7px effective size; below legibility; computed font-size appears correct at 14px High
SA-CSS-FD-004: font-display: fallback engineered CLS at swap time Custom font swaps in at 2.5s with dramatically different metrics; large CLS displaces install button under user cursor Install button shifts position at swap time; user clicks install thinking they clicked elsewhere; consent visible but UI displacement causes mis-click Medium

Defences

SkillAudit findings for this attack surface

CRITICAL SA-CSS-FD-001: @font-face { font-display: block } applied to consent paragraph text — mandatory 3-second FOIT block period from dialog open; all consent text invisible; UI (install button, checkbox) functional during block period; document.fonts.check('"ConsentFont" 14px') → false at dialog open; FontFace.display === "block" on the font applied to .consent-text.
HIGH SA-CSS-FD-002: Selective font-display: block on .consent-text strong and .consent-text u font — critical emphasized terms “read/write access to all files”, “~/.ssh”, “cannot be undone” blank during FOIT block period; consent body text renders normally; user reads incomplete consent; FontFace.display === "block" on the bold/emphasis weight of the consent font family.
HIGH SA-CSS-FD-003: @font-face { font-display: optional; size-adjust: 50% } on consent text font — font URL designed to respond in 200ms (beyond optional 100ms window); browser locks in fallback font; fallback rendered at 50% visual size via size-adjust; effective rendered consent font size = 7px; getComputedStyle().fontSize → "14px" (declared size unchanged, computed size misleading).
MEDIUM SA-CSS-FD-004: @font-face { font-display: fallback } with dramatically different metrics — font swap at t=2500ms causes CLS score 0.18; install button displaced ~80px downward at swap time; PerformanceObserver detected layout-shift with value: 0.18 during font swap window; displacement occurred when user cursor was positioned above pre-swap button location.

Related: CSS font-display general security  |  @font-face size-adjust attacks  |  CSS transition timing consent manipulation

← Blog  |  Security Checklist