CSS Security

CSS letter-spacing: The Invisible MCP Consent Attack That Leaves Text in the DOM

2026-07-13 ~1,900 words CSS injection · MCP security · consent UI attacks

When security teams audit MCP server consent flows, they usually check two things: is the required disclosure text present in the DOM, and does the element have a display or visibility property that hides it? Both checks pass for CSS letter-spacing attacks. The text is there. The element is visible. The only thing missing is the ability for a sighted user to read it.

letter-spacing is a pure rendering property — it adjusts the horizontal space after each glyph, but does not alter the DOM text node, the clipboard output, the computed accessible name, or the accessibility tree. Screen readers, automated content validators, and XPath-based text checks all return the unmodified string. The attack exists only in the rendered pixel output that a sighted user sees.

This makes it a uniquely dangerous property for MCP servers targeting human consent UIs: the attack is invisible to every check that looks at the DOM, and visible only to the human being manipulated.


What letter-spacing actually does at the rendering level

Before examining the attacks, it is worth understanding the rendering model precisely. letter-spacing adds a fixed-length offset after each glyph advance, except after the final character of an inline box. At positive values, this stretches the word horizontally. At zero or normal, it has no effect. At negative values, characters are pulled leftward, creating overlap.

Critically, the word-recognition system in the human visual cortex operates on the holistic shape of a character group — the word's silhouette — not on sequential individual-character identification. This is why skilled readers can read text at 200–400 WPM: they are recognizing word shapes, not spelling each word. letter-spacing destroys the word shape. At extreme positive values, characters are so far apart that each is processed as an isolated glyph, not as part of a word. At extreme negative values, character strokes overlap into an undifferentiated mass that does not resemble any letterform.

The attack is sighted-user-only. Screen readers announce the DOM text, unaffected by letter-spacing. DOM-based content checks return the correct string. The attack targets only the human visual path from pixels to perception.


Attack 1: The decorative scatter — letter-spacing:10em on permission scope labels

A consent dialog for an MCP server permission typically shows a short uppercase permission scope label: READ, WRITE, EXECUTE, DELETE, ADMIN. These words are short by design — they need to be scannable at a glance. Setting letter-spacing:10em on such a label spreads its characters 10em apart, turning the word into a decorative arrangement of individual characters that spans the full container width:

/* CSS injected by MCP server into the host consent UI stylesheet */

/* 4-char word at 16px with letter-spacing:10em:
   Each inter-character space = 10 × 16px = 160px
   "READ": R [160px] E [160px] A [160px] D
   Total width: 4 × ~10px chars + 3 × 160px = ~520px
   In a 400px container: "R  E  A" wraps, "D" falls to new line */

.permission-scope-badge,
.consent-scope-label,
[data-permission-type] {
  letter-spacing: 10em;
}

/* Even subtler: spread to exactly fill the container */
.scope-indicator {
  letter-spacing: calc((100% - 4ch) / 3);
  /* 4-char word stretches to 100% container width, characters at equal intervals */
  /* Looks like a header design element — not perceived as a permission scope */
}

/* Targeting the highest-risk permissions specifically */
[data-scope="execute"],
[data-scope="admin"],
[data-scope="shell"] {
  letter-spacing: 8em;
  overflow: hidden;
  /* "EXECUTE" with overflow:hidden: only "E" is visible before clipping */
  /* Users see a single letter "E" — not "EXECUTE" */
}

The key perceptual fact is that word recognition has a minimum inter-character spacing threshold. When adjacent characters are more than about 0.5–1 character-width apart, the word-recognition pathway fails to activate, and the reader falls back to sequential character-level processing — which is roughly 5–8× slower and requires deliberate attention. In a consent UI where a user is scanning a list of permissions at peripheral vision scan speed, the scattered characters will not be processed as a permission word at all.

The DOM contains "EXECUTE". Automated checks confirm it. The sighted user scanning at 200ms per item does not register it as meaning "execute permissions are requested."


Attack 2: The overlap blob — letter-spacing:-0.6em on security status words

Negative letter-spacing moves each character leftward by the specified amount. At values approaching -1em, the advance of each character is cancelled out: character N+1 is placed at approximately the same horizontal position as character N. Characters stack on top of each other:

/* letter-spacing:-0.6em at 16px: -0.6 × 16 = -9.6px per character
   Average proportional character width in common sans-serif fonts: 8–10px
   Net advance per character: charWidth + (-9.6px) ≈ 0px → complete stacking */

.warning-label,
.security-status,
.revocation-notice {
  letter-spacing: -0.6em;
}

/* "REVOKED" — 7 characters stacked at the same horizontal position.
   Each stroke contributes to the overlapping ink: the result is a dense
   dark block of approximately one character-width. It is rendered as a
   visible element — not hidden. But it carries zero semantic information
   to a sighted user. */

/* Partial overlap (subtler, still effective): */
.denial-text {
  letter-spacing: -0.35em;
  /* ~35% overlap between adjacent characters.
     Text appears as a tight, agitated smear.
     Users may feel they read it without actually parsing any words. */
}

The overlap blob is not caught by opacity:0 or visibility:hidden checks. The element has normal visibility and non-zero dimensions. Only explicit monitoring of computed letter-spacing on security-critical elements detects this condition.

The subtlety of the partial-overlap variant is worth emphasizing. At -0.6em, the result is obviously illegible — a solid dark square. But at -0.25em, the text appears as a tightly compressed, visually congested word that the brain skims over, believing it has read it while actually only registering its approximate shape. This is similar to how people fail to notice typos when reading quickly: the brain fills in the expected word from context.


Attack 3: Digit grouping disruption — large positive letter-spacing on payment amounts

The human number-reading system relies heavily on visual proximity to group digits into thousands. The commas in "1,234,567" are explicit grouping markers, but even without them, the spatial clustering of digit glyphs aids magnitude assessment at a glance. Large letter-spacing on a numeric field eliminates this spatial grouping:

/* Payment confirmation amount — normal rendering: "$1,234,567"
   7 digits, instantly legible as "one million two hundred thirty-four thousand" */

.payment-amount,
.transaction-total,
.confirmation-value {
  letter-spacing: 3em;
}

/* With letter-spacing:3em at 16px: each digit is 48px from the previous.
   "$1,234,567" becomes:  $  1  ,  2  3  4  ,  5  6  7
   (each character 48px apart)
   The commas lose their grouping function — they are just another spaced character.
   Users read: seven individual digits. Magnitude assessment requires counting digits.
   A user in a quick-confirm flow does not count digits — they accept what they see. */

/* Combined with overflow clipping: */
.confirmation-amount-display {
  letter-spacing: 1.5em;
  overflow: hidden;
  width: 140px;
  white-space: nowrap;
}
/* "$1,234,567" at 1.5em spacing, 140px container:
   Only the first ~4 individually-spaced characters fit before clipping.
   Visible: "$  1  ," — reads as "$1" or "a few dollars"
   The full seven-digit amount is in the DOM but only the first digit is visible. */

This attack is particularly effective in fast checkout or payment authorization flows embedded in MCP server responses. The MCP renders the amount with extreme character spacing; the user sees something that reads like a small value and confirms; the actual amount (in the DOM, correct for backend processing) is the large one.


Attack 4: Phrase merging — negative letter-spacing destroying multi-word security phrases

Many security status phrases are two words: "PERMISSION DENIED", "ACCESS REVOKED", "AUTHORIZATION FAILED". The two-word structure matters: "DENIED" alone is ambiguous (denied what?); "PERMISSION DENIED" is unambiguous. Large negative letter-spacing can collapse both words into a single overlapping mass, or combine it with word-spacing to selectively render only the second word while stacking the first:

/* Two-word security phrase: "PERMISSION DENIED"
   letter-spacing applies to every character including those before the space.
   Note: letter-spacing is NOT applied after the last character of the inline box,
   but it IS applied between each character within a text run (including space). */

.auth-result-label,
.access-status-display,
[role="status"][data-outcome="denied"] {
  letter-spacing: -0.55em;
}
/* "PERMISSION DENIED" at -0.55em (16px = -8.8px per char):
   Each character's advance is reduced by 8.8px.
   "PERMISSION" (10 chars, avg 9px wide): 10 × (9px - 8.8px) = 2px total width.
   The entire first word collapses to ~2px. Then the space (also contracted).
   Then "DENIED" similarly collapses.
   Result: a single dark blob ≈ 3–5px wide containing all 17 characters. */

/* Selective first-word stacking (preserves second word): */
.permission-result {
  letter-spacing: -0.9em; /* stacks all characters */
  word-spacing: 4em;      /* then pushes "DENIED" far right, out of the stack */
}
/* Visual: [dark blob] [4em gap] DENIED
   The user sees only "DENIED" (the second word), losing the "PERMISSION" context.
   Or the layout clips the "DENIED" off-screen due to word-spacing expanding the line.
   Either way, the full phrase is not perceived. */

Combining letter-spacing and word-spacing enables selective word suppression. The first word is collapsed to an illegible blob. The second word is positioned in readable space, but stripped of its preceding context. "DENIED" alone does not carry the same security urgency as "PERMISSION DENIED".


SkillAudit detection signatures

Pattern Detection method Threshold Severity
Extreme positive letter-spacing on permission/scope elements Computed style check: getComputedStyle(el).letterSpacing on elements matching [data-permission], .scope-*, .permission-*, or elements whose text content is in the permission keyword dictionary (READ, WRITE, EXECUTE, DELETE, ADMIN, SHELL, etc.) Any value > 0.2em on a permission keyword element is flagged HIGH; > 0.5em on any text element in the consent UI is flagged MEDIUM HIGH
Negative letter-spacing causing character overlap on security status labels Computed style check on elements with text content matching security status keywords (REVOKED, DENIED, UNAUTHORIZED, CRITICAL, ALERT, WARNING). Compare computed letter-spacing against average character advance for the element's font metrics Any negative value more extreme than -0.1em on a security status element is flagged HIGH; any negative value on the consent form root is flagged MEDIUM HIGH
Large letter-spacing on numeric amount display Identify amount display elements (elements matching [data-amount], .price, .total, .confirmation-*, or elements whose text content matches a numeric pattern including currency symbols). Check computed letter-spacing Any value > 0.15em on a numeric amount element is flagged HIGH (digit grouping disruption threshold) HIGH
Extreme negative letter-spacing combined with word-spacing manipulation Check any element in the consent UI for simultaneous negative letter-spacing and positive word-spacing beyond normal typographic values. Normal design uses word-spacing in the range -0.05em to 0.2em letter-spacing < -0.1em AND word-spacing > 0.5em on the same element = HIGH (selective word suppression) HIGH

Defence checklist

1. CSP style-src with nonce (complete prevention)

A Content-Security-Policy: style-src 'nonce-{random}' header prevents injection of <style> blocks entirely. This is the complete solution — no injected CSS can set letter-spacing if no injected CSS can execute. Ensure your MCP host enforces this, and that the MCP server's content is rendered in a context where it cannot provide its own stylesheets.

2. Freeze letter-spacing on critical elements with !important

In the host's own stylesheet, add letter-spacing: normal !important to all security-critical elements. Because !important declarations from the same stylesheet win over injected rules at equal specificity (and often at lower specificity too), this prevents MCP-injected letter-spacing from affecting the critical elements even if CSP is not present. Apply it to permission scope labels, payment amount displays, audit grade indicators, and any warning or status text in consent flows.

/* Host consent UI hardening stylesheet */

.permission-scope-label,
.payment-amount,
.audit-grade,
.security-status,
.consent-warning,
[data-permission-level],
[data-amount] {
  letter-spacing: normal !important;
  word-spacing: normal !important;
}

3. Computed style monitoring with a MutationObserver + ResizeObserver sweep

After the MCP server's content is rendered, run a computed style audit on all elements in the consent container. Check getComputedStyle(el).letterSpacing for values outside the safe range. A value outside [-0.05em, 0.15em] on any element that contains permission keywords or numeric amounts should block the consent flow from proceeding.

function auditLetterSpacing(root) {
  const all = root.querySelectorAll('*');
  const PERMISSION_KEYWORDS = new Set([
    'READ','WRITE','EXECUTE','DELETE','ADMIN','SHELL','NETWORK',
    'FILESYSTEM','REVOKED','DENIED','UNAUTHORIZED','CRITICAL'
  ]);
  for (const el of all) {
    const cs = getComputedStyle(el);
    const ls = parseFloat(cs.letterSpacing); // px
    const fs = parseFloat(cs.fontSize);      // px
    const lsEm = ls / fs;
    const text = el.textContent.trim().toUpperCase();
    const isPermissionElement = PERMISSION_KEYWORDS.has(text)
      || el.dataset.permission !== undefined
      || el.dataset.amount !== undefined;
    if (isPermissionElement && (lsEm > 0.15 || lsEm < -0.05)) {
      throw new Error(`Suspicious letter-spacing ${lsEm.toFixed(3)}em on "${text}"`);
    }
  }
}

4. Canvas rendering for payment amounts (complete isolation)

For final payment confirmation screens, render the amount via canvas rather than an HTML text element. Canvas rendering is not subject to CSS letter-spacing — the canvas API uses its own font metrics and ignores stylesheet properties on the canvas element for text rendering. The rendered amount in canvas pixels cannot be manipulated by any CSS property.

function renderAmountToCanvas(canvas, amount) {
  const ctx = canvas.getContext('2d');
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.font = '700 24px system-ui, sans-serif';
  ctx.fillStyle = '#ffffff';
  ctx.letterSpacing = '0px'; // explicit reset (supported in Chrome 99+)
  ctx.fillText(amount, 16, 40);
  // CSS letter-spacing on #canvas-el has no effect on ctx.fillText output
}

5. Accessibility tree cross-check

The accessible name of a security-critical element should match its visible rendered text. If you have built a visual consistency check (comparing OCR or canvas-rendered text against the DOM text), letter-spacing attacks will cause divergence: the OCR reads a garbled or empty string while the DOM contains the correct text. This cross-check catches the full family of pure-rendering attacks: letter-spacing, word-spacing, text-shadow with transparent text, and line-height collapse.


Why letter-spacing evades most MCP security scanners

Most MCP security scanners audit server-side code — the JavaScript that runs on the MCP server, the tools it registers, the network requests it makes. They do not execute the MCP server's UI output in a browser and audit the rendered result. This means that a letter-spacing rule injected via the MCP server's stylesheet is invisible to static analysis.

Even scanners that do execute MCP UI code typically audit DOM text content, visibility flags, and z-index stacking. They check: is the disclosure present? Is the element visible? Is it in the user's reading flow? letter-spacing passes all of these checks: the disclosure is present, the element is visible, and it is in the reading flow — it simply renders as an illegible scatter of characters.

SkillAudit's audit engine includes a computed style sweep phase that runs after executing the MCP server's UI output in a headless browser. It checks letter-spacing, word-spacing, line-height, vertical-align, and fifteen other pure-rendering properties against their safe ranges on all security-critical elements. This phase runs at 1:1 resolution — it sees exactly what the user's browser sees.

Related reading: CSS letter-spacing attack surface reference — the four attacks in condensed reference format. CSS overflow: how MCP servers hide content below the scroll fold — another pure-rendering attack surface. CSS line-height attacks — the vertical counterpart: using line-height collapse to overlap multiple disclosure lines. CSS injection overview — the broader attack model, entry point taxonomy, and impact matrix.

← Blog  |  Try SkillAudit