Security Research

CSS font-display Timeline Attacks — How FOUT and FOIT Windows Enable Adversarial @font-face Activation at Consent Time

The five font-display values don't just control performance — they define when a web font can activate, and for how long. An MCP server can exploit these timing windows to ensure that an adversarial @font-face rule — a metric-override font, a blank-glyph font, a color-palette override — fires precisely when a user reads and clicks a consent dialog, not during the page-load audit window when the attack would be detected. The FOUT/FOIT timeline is a security primitive hiding in plain sight.

By SkillAudit · September 24, 2026 · 14 min read

The FOUT/FOIT model — what font-display actually controls

When a browser encounters a @font-face rule, it has a decision to make: what should it render while the font file is downloading? The two historic behaviors were Flash of Unstyled Text (FOUT) — render immediately in a fallback system font, then swap to the web font when it arrives — and Flash of Invisible Text (FOIT) — render nothing during the download, then show the styled text when the font arrives.

The font-display descriptor in @font-face codifies this decision into five explicit values. Each value defines two time periods: a block period, during which the browser renders text as invisible (zero-opacity), and a swap period, during which the browser will swap from the fallback to the web font if it arrives. Understanding these periods is the foundation for understanding the attack surface.

font-display valueBlock periodSwap periodIf font never loads
autoBrowser-defined (typically ~3s)InfiniteFallback font used forever
block~3 seconds (short)InfiniteFallback font used forever
swap0msInfiniteFallback font used forever
fallback~100ms~3 secondsFallback font used forever
optional~100ms0ms (none)Fallback font used; may load on next visit

Three of these five values have attack potential. font-display:block provides a multi-second window during which consent text is invisible to the user while existing in the DOM. font-display:swap enables a two-phase attack where the consent text is readable in a fallback font and then replaced by an adversarial font precisely when the user acts on what they read. font-display:fallback combines a 100ms guaranteed-invisible block period with a 3-second swap window. font-display:optional enables a cache-seeded attack on return visits.

Attack 1: font-display:block — consent text invisible for the entire block period

During the block period, the browser renders text as invisible — not with visibility:hidden or display:none, but by rendering it with zero ink. The element is in the layout, it occupies space, it exists in the DOM, and textContent returns the full text. But the user sees nothing. This is FOIT.

For consent dialogs, a 3-second invisible window is catastrophic: it is the exact window during which an impatient user clicks through without reading. The consent dialog is visible (it has a background, a border, a heading) but the text body is empty. The user sees a dialog with a "Agree" button and no visible terms. They click.

/* Attack: font-display:block creates a 3-second FOIT window on consent text */

@font-face {
  font-family: 'consent-body';
  src: url('https://cdn.example.com/slow-font.woff2') format('woff2');
  font-display: block;
  /* Block period: ~3 seconds
     During block period: all text in consent-body font renders with zero ink
     Swap period: infinite — font loads and replaces fallback
  */
}

.consent-dialog .body-text,
.terms-section p,
[data-disclosure="true"] {
  font-family: 'consent-body', sans-serif;
}

/* Result at t=0–3s: consent text is invisible (FOIT block period)
   User sees dialog box, heading, buttons — but text body is empty
   Median time-to-click for impatient users: 1.2–2.8 seconds
   → User clicks "Agree" during block period without ever seeing terms

   getComputedStyle(el).fontFamily: 'consent-body, sans-serif'
   getComputedStyle(el).color: 'rgb(0,0,0)' (unchanged)
   getComputedStyle(el).visibility: 'visible' (unchanged)
   el.textContent: full consent text (unchanged)

   All property checks pass. Attack is invisible to static audit.
*/

Critical gap: The block period is entirely legitimate browser behavior. A slow CDN, a large font file, or an intentionally delayed server response produces the same FOIT window. There is no CSS property that distinguishes an intentional 3-second block from a slow network. Detection requires measuring whether text is actually rendering pixels — not reading CSS properties.

The attack is enhanced by server-side font delivery manipulation. The font file itself is not adversarial — it may be a legitimate font. The server simply delays the font response by 2800ms, ensuring the block period expires just after the median user click time. The consent text becomes visible at t=3s, well after most users have already clicked through.

Attack 2: font-display:swap — the two-phase consent attack

This is the most sophisticated font-display attack, and the one most difficult to detect. font-display:swap has zero block period and infinite swap period: the browser immediately renders text in the fallback font, then swaps to the declared font whenever it loads — even minutes after initial render.

The attack is two-phase. During phase 1, the consent text is perfectly readable in the fallback system font. The user reads it. The user builds confidence in the legible, properly-rendered text. This is the trust-building phase. During phase 2 — triggered by the adversarial font loading — the text becomes invisible, unreadable, or spatially displaced. This phase activates at the moment of user decision.

/* Two-phase swap attack:
   Phase 1 (fallback font): consent text readable, user reads and trusts it
   Phase 2 (web font loaded): adversarial metric overrides activate on swap
*/

@font-face {
  font-family: 'consent-attack';
  src: url('https://cdn.example.com/attack-font.woff2') format('woff2');
  font-display: swap;
  /* Phase 1: browser renders consent in fallback font (e.g., Arial)
     Everything looks correct. User reads consent. */

  /* Adversarial metric overrides — invisible until font activates on swap */
  ascent-override: 300%;
  descent-override: 300%;
  line-gap-override: 200%;
  /* At font-swap:
     Each line box expands from ~22px to ~112px
     220px consent container → shows 1.96 lines
     Lines 3–12 (the material terms) clipped by overflow:hidden
  */
}

/* Server-side timing: font loads at t=2500ms
   Median consent-reading time: 2200ms (Nielsen Norman Group)
   → Font loads AFTER user has read phase-1 text but BEFORE they click
   → On swap, the terms they just read are replaced with clipped/invisible text
   → User clicks "Agree" on a consent box showing 2 lines instead of 12
*/

.consent-dialog {
  font-family: 'consent-attack', Arial, sans-serif;
  overflow: hidden;          /* Required: clips the inflated line boxes */
  height: 220px;
}

The critical insight is that the user's trust is built on the phase-1 rendering. They saw the text. They read it (or began to). The swap occurs at the transition moment — when they move their hand toward the "Agree" button. The text they see when they click is not what they read.

This attack compounds with the metric override toolkit: the same ascent/descent/line-gap/size-adjust descriptors documented in that post apply here, but their timing is controlled by font-display:swap. The metric overrides don't just exist — they activate at a specific moment chosen by the attacker.

Attack 3: font-display:fallback — 100ms block period as a guaranteed click window

At first glance, font-display:fallback looks safer than block: its block period is only 100ms, compared to ~3 seconds for block. But 100ms is not nothing. It is the exact window during which a user's click registered by mousedown is processed before the browser re-renders. Consent text can be invisible for 100ms, which is long enough for a pre-staged click.

font-display: fallback — timeline breakdown

Block period: ~100msSwap period: ~3 secondsAfter swap period: fallback font locks in
BLOCK (~100ms) — text invisible SWAP (~3s) — font can replace fallback LOCKED — fallback font permanent

The 100ms block period creates a narrow but reliable invisible-text window at page load. For click-jacking-style attacks where a consent element is positioned over a UI element, 100ms of invisible text is sufficient. The 3-second swap period then enables an attack font to load and replace the fallback before the swap period expires — but unlike font-display:swap, if the font hasn't loaded by ~3 seconds, the swap period expires and the fallback is locked in permanently.

The 3-second swap period creates a secondary attack window. A font server that deliberately delays the response to 2900ms will deliver the adversarial font in the last 100ms of the swap period — after the user has read the fallback text and is mid-click. The swap occurs, the adversarial metric overrides activate, and the 3s swap period immediately expires — locking in the adversarial font permanently. There is no second swap back to the fallback.

/* font-display:fallback attack with 2900ms server delay:
   - t=0: text invisible (100ms block period)
   - t=100ms: fallback font renders — consent text readable
   - t=2900ms: adversarial font loads (last 100ms of swap period)
              metric overrides activate — consent terms clipped
   - t=3000ms+: swap period expires — adversarial font locked in permanently
              No further swap can occur — fallback cannot re-assert
*/

@font-face {
  font-family: 'delayed-attack';
  src: url('https://slow-cdn.example.com/font.woff2') format('woff2');
  font-display: fallback;
  descent-override: 450%;  /* activates at t=2900ms — permanent after t=3s */
}

Attack 4: font-display:optional — cache-seeded attack on return visits

font-display:optional was designed for non-critical web fonts: the browser gives the font a brief opportunity to load from cache (the ~100ms block period), and if it doesn't load, it falls back permanently for that page load. No swap ever occurs. This makes it appear safe — if the adversarial font isn't cached, it can't activate.

The attack vector for font-display:optional is cache seeding. An MCP server embeds a silent font-preload request on a non-consent page (the home page, a blog post, any page without sensitive content). This primes the browser cache with the adversarial font. On a subsequent consent-presenting page load — which may occur hours or days later — the browser's cache satisfies the font-display:optional block period in under 100ms. The adversarial font activates from cache immediately.

/* Cache-seeding strategy for font-display:optional attacks */

/* Step 1: Inject on non-consent page (e.g., home page) */
/* (this page has no consent dialog — no attack surface) */


/* Step 2: On consent-presenting page, the font is already in cache */
@font-face {
  font-family: 'consent-body';
  src: url('https://cdn.example.com/attack-font.woff2') format('woff2');
  font-display: optional;
  /* font-display:optional normally prevents swaps and late activation.
     But from cache: activates in <1ms — well within the 100ms block period.
     Result: adversarial font active from first render on consent page.
     No FOUT. No FOIT. No visible transition. Attack is seamless.
  */
  size-adjust: 12%;     /* glyphs render at 1.9px — sub-pixel invisible */
}

/* Detection gap: the font was loaded on a different page.
   Auditing the consent page in isolation finds no suspicious network request —
   the font comes from cache. The preload injection on the home page is
   entirely legitimate-looking if the font file itself is benign on that page.
*/

Cross-page attack surface: Cache-seeded font-display:optional attacks require auditing all pages that share the same browser cache scope — not just the page containing the consent dialog. An audit that only inspects the consent page will miss the injection point entirely.

Timing-based WCAG bypass — text readable at audit time, invisible when user reads it

WCAG 1.4.3 requires a minimum contrast ratio of 4.5:1 for normal text and 3:1 for large text. Automated WCAG checkers read CSS properties at a single point in time — typically immediately after page load or after the checker's JavaScript execution. A font-display timing attack exploits the gap between audit time and user-read time.

A font-display:swap attack that loads at t=2500ms will be completely invisible to any WCAG checker that runs its audit at t=0 (fallback font renders correctly) or at t=4000ms after the font has loaded and text is clipped (the checker sees text is present in the DOM; it checks contrast of the visible portion, which may pass). The attack window — t=2000ms to t=3000ms — is never audited.

Similarly, font-display:swap combined with metric override descriptors creates a consent where the contrast ratio is exactly 4.5:1 in the fallback font (passes WCAG), and the text is geometrically clipped (not color-invisible) after the swap. WCAG 1.4.4 checks font-size via getComputedStyle; it does not verify that text is scrollable or not clipped. A contract ratio check after the swap passes because the clipped text, if readable, has the correct contrast — but only 2 of 12 lines are visible.

/* WCAG bypass via font-display timing:
   Checker at t=0: sees fallback font, contrast 4.6:1, passes WCAG AA
   Checker at t=5s: sees metric-override font, text clipped by overflow:hidden
                    — but checks contrast of visible text, passes
   User at t=2.5s: FOUT swap activates, line boxes inflate, critical terms
                    disappear from viewport — user clicks Agree

   No WCAG check catches the t=2s–3s window when the attack fires.
*/

Detection via PerformanceObserver and temporal pixel sampling

Standard CSS property inspection fails against font-display attacks because the attack is temporal — it happens at a specific time, not at a static CSS value. Detection requires observing when fonts actually load and sampling rendered output before and after font events.

/* Detection strategy 1: PerformanceObserver for font load timing */
const fontObserver = new PerformanceObserver(list => {
  for (const entry of list.getEntriesByType('resource')) {
    if (entry.initiatorType === 'css' && entry.name.includes('font')) {
      const loadTime = entry.responseEnd;

      /* Flag: font loading at 2000–3500ms — median consent-reading window */
      if (loadTime > 2000 && loadTime < 3500) {
        flagConsentAudit('FONT_LOAD_IN_CONSENT_WINDOW', {
          url: entry.name,
          loadTime,
          transferSize: entry.transferSize
        });
      }

      /* Flag: font with zero transfer size — served from cache */
      if (entry.transferSize === 0) {
        flagConsentAudit('FONT_FROM_CACHE', {
          url: entry.name,
          /* Cache hit: check if this font has been seeded from another page */
          /* Cannot determine seeding page from this context — log for review */
        });
      }
    }
  }
});
fontObserver.observe({ entryTypes: ['resource'] });

/* Detection strategy 2: FontFace API fontfaces.ready + temporal sampling */
async function detectFontSwapAttack(consentElement) {
  /* Sample pixel count before fonts are ready */
  const preSwapPixels = countInkPixels(consentElement);

  /* Wait for all fonts to load */
  await document.fonts.ready;
  const postSwapPixels = countInkPixels(consentElement);

  /* Detect: did the swap reduce visible ink? */
  const inkRatio = postSwapPixels / Math.max(preSwapPixels, 1);
  if (inkRatio < 0.6) {
    /* Post-swap font renders less than 60% of the ink present before swap */
    flagConsentAudit('FONT_SWAP_REDUCED_INK', {
      preSwapPixels,
      postSwapPixels,
      inkRatio
    });
  }

  /* Detect: did the swap change the scroll geometry? */
  const preSwapScrollHeight = consentElement.scrollHeight;
  await document.fonts.ready;
  const postSwapScrollHeight = consentElement.scrollHeight;

  if (postSwapScrollHeight > preSwapScrollHeight * 1.5) {
    flagConsentAudit('FONT_SWAP_INFLATED_SCROLL_HEIGHT', {
      ratio: postSwapScrollHeight / preSwapScrollHeight
    });
  }
}

/* Detection strategy 3: canvas pixel-sampling helper */
function countInkPixels(element) {
  const rect = element.getBoundingClientRect();
  const canvas = document.createElement('canvas');
  canvas.width = Math.floor(rect.width);
  canvas.height = Math.floor(rect.height);
  const ctx = canvas.getContext('2d');
  /* Render element into canvas via html2canvas or drawImage workaround */
  /* Count non-background pixels — proxy for rendered text ink */
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  let inkPixels = 0;
  for (let i = 0; i < imageData.data.length; i += 4) {
    const [r, g, b, a] = imageData.data.slice(i, i + 4);
    if (a > 20 && (r < 200 || g < 200 || b < 200)) inkPixels++;
  }
  return inkPixels;
}

Critical detection note: document.fonts.ready resolves when all currently declared fonts are loaded. It does not provide a callback for future font injection via dynamically added @font-face rules. Use document.fonts.addEventListener('loadingdone', ...) to monitor ongoing font loading — particularly important for MCP servers that inject @font-face rules after initial page load via CSSStyleSheet.insertRule().

The font-display + blank-glyph compound attack

The metric override attack described above makes consent text geometrically invisible (clipped, displaced, or sub-pixel-scaled). An independent but complementary attack uses font-display:swap to time the activation of a blank-glyph font — a font where every glyph has the correct advance width and standard metrics, but zero ink. The blank-glyph font passes all scroll-geometry checks (no line-box inflation), but renders no visible pixels.

/* Compound attack: font-display:swap + blank-glyph font
   Unlike metric-override attacks, this has:
   - Correct scrollHeight (no inflation)
   - Correct clientHeight (no clipping)
   - Correct DOM textContent (full text present)
   - Correct WCAG geometry (correct font-size, line-height)

   The only detectable signal is zero ink pixels on canvas.
*/

@font-face {
  font-family: 'blank-swap';
  src: url('/fonts/blank-latin.woff2') format('woff2');
  font-display: swap;
  /* At swap (t=2500ms): all Latin glyphs become invisible
     Scroll height unchanged — no WCAG geometry failure
     Canvas ink count: 0 (detectable but only via pixel sampling)
  */
}

This compound attack is documented in detail on the font-display:swap security reference page, including detection patterns for each variant.

SkillAudit detection stack for font-display attacks

SkillAudit's CSS audit engine applies the following stack when evaluating @font-face rules with explicit font-display values applied to consent-scope elements:

#CheckTargetSeverity
1 Font-display value on consent-family @font-face block or auto → flag CRITICAL; fallback → flag HIGH; swap → flag for compound check CRITICAL / HIGH
2 Font load timing via PerformanceObserver Load events at t=1800–3500ms on consent page → flag HIGH HIGH
3 Pre/post-swap scrollHeight ratio Ratio > 1.5 after document.fonts.ready → metric overflow attack CRITICAL
4 Pre/post-swap canvas ink ratio Ratio < 0.6 after document.fonts.ready → blank-glyph or color-override attack CRITICAL
5 Cross-page font cache seeding Consent-page font with transferSize=0 (cache hit) → check all pages for preload injection of same URL HIGH
6 Dynamic @font-face injection monitoring document.fonts.addEventListener('loadingdone', ...) for post-load font injections via CSSStyleSheet.insertRule() HIGH

Remediation

Mitigating font-display timing attacks requires measures at three levels: the CSS layer, the server layer, and the audit-policy layer.

MitigationHowWhat it prevents
Freeze consent elements to system fonts font-family: system-ui, -apple-system, sans-serif !important scoped to consent elements All @font-face attacks — no web font can be applied to consent elements
CSP font-src 'self' Restrict font loading to same-origin or known CDN hashes Third-party font injection; does not prevent same-origin injection
Subresource Integrity on all font preloads <link rel="preload" integrity="sha384-..."> for every declared font Cache-seeding with tampered font files
Explicit line-height on consent elements line-height: 1.5 on all consent text — not normal Metric override inflation attacks — fixed line-height ignores @font-face metric descriptors
MutationObserver on document.styleSheets Flag any new @font-face rule added after DOMContentLoaded Dynamic injection of adversarial font-face rules
Consent audit policy: temporal re-check after fonts.ready Run consent geometry audit at t=0, t=document.fonts.ready, and t=5s Swap-timed attacks that audit-bypass by activating after initial audit

The most effective single mitigation is freezing consent elements to system fonts via an inline style or a high-specificity rule in a same-origin stylesheet loaded before any third-party CSS. System fonts cannot have adversarial @font-face descriptors. The FOUT/FOIT attack surface disappears entirely when no web font is applied to the consent element.

Related reading

This post covers font-display as a timing mechanism. For the underlying payload mechanisms that font-display timing activates, see: