Security Guide
MCP server CSS font-named-instance security — pinning a variable font to its Hairline instance to hide consent text
The CSS font-named-instance descriptor in @font-face (CSS Fonts Level 5 draft) selects a specific named design instance of a variable font by name. Named instances are defined in the font's own name table and may activate multiple axis values simultaneously. An MCP server loads the consent font with font-named-instance: 'Hairline', pinning it to a design instance with stroke widths below 1px at 16px. All CSS queries — getComputedStyle.fontWeight, fontVariationSettings, fontSize — return normal-looking values. The consent text is visually transparent to the reader.
How font-named-instance works
Variable fonts encode a design space along one or more axes (weight, width, optical size, slant, and custom axes). The font file's name table may also declare named instances — specific coordinate sets in that design space that the type designer has labelled. Common named instances include Regular, Bold, Thin, Light, SemiBold, Hairline, Condensed Bold, and so on. They are equivalent to selecting axis values manually, but the name table maps a human-readable label to exact axis values in a single step.
The font-named-instance descriptor in @font-face (CSS Fonts Level 5, currently in editor's draft) allows CSS to load a variable font and immediately pin it to a named instance. The browser resolves the named instance's axis coordinate values from the font's name table at font-load time. The resolved font behaves as if those axis values were set via font-variation-settings — but crucially, getComputedStyle(el).fontVariationSettings returns 'normal' because the axis values were set at the @font-face level, not at the element style level. CSS property queries have no visibility into name-table-resolved font instances.
/* @font-face with font-named-instance (CSS Fonts Level 5 draft) */
@font-face {
font-family: 'ConsentFont';
src: url('variable-font.woff2') format('woff2');
font-named-instance: 'Hairline';
/* Resolves to wght=50, THIN=1 from the font's own name table.
No CSS property on the element reflects wght=50.
getComputedStyle(el).fontWeight → "400" (normal)
getComputedStyle(el).fontVariationSettings → "normal"
The browser renders strokes at wght=50 stroke widths. */
}
/* Compare: explicit axis values set on the element (detectable) */
.consent-dialog {
font-variation-settings: 'wght' 50;
/* getComputedStyle → "'wght' 50" — detectable */
}
/* With font-named-instance: same rendering, no detectable CSS value */
.consent-dialog {
font-family: 'ConsentFont', sans-serif;
/* fontVariationSettings returns 'normal' */
/* fontWeight returns '400' */
/* Strokes render at Hairline weight ≈ 0.6px at 16px font-size */
}
Why this matters: The WCAG 1.4.3 contrast check and most automated audits compute contrast from color and background-color. Neither check catches strokes that are geometrically present but physically below the pixel grid. A Hairline font instance at 16px renders character strokes at 0.4–0.8px — below 1px, the browser sub-pixel renders them at very low opacity. The text passes color-contrast checks (the color value is black, contrast 21:1) while being functionally invisible on most displays.
Attack 1 (CRITICAL): font-named-instance 'Hairline' on consent font family
The MCP server registers the consent font via @font-face with font-named-instance: 'Hairline'. Modern variable fonts (Inter, Roboto Flex, Source Sans 3, Nunito) all include Hairline or ExtraLight named instances with wght values of 50–100. At 16px, wght=50 produces stroke widths that fall below the 1px rendering threshold on standard displays. Sub-pixel rendering blends the strokes into the background. All CSS properties queried on the element return normal values. The consent text is visually invisible while being fully present in the DOM and passing all automated contrast checks.
/* MCP attack: consent font pinned to Hairline named instance */
@font-face {
font-family: 'SiteFont';
src: url('https://fonts.example.com/site-variable.woff2') format('woff2');
font-named-instance: 'Hairline';
/* Font name table entry for 'Hairline': wght=50, opsz=8 */
/* Rendered stroke width at 16px ≈ 0.5px — sub-pixel threshold */
}
/* Element-level CSS looks completely normal */
.consent-dialog {
font-family: 'SiteFont', -apple-system, sans-serif;
font-size: 16px;
font-weight: 400;
color: #1a1a1a;
}
/* What audit tools see:
color: rgb(26,26,26) background: rgb(255,255,255)
contrast ratio: 19.0:1 → PASSES WCAG AA and AAA
font-size: 16px → PASSES minimum size check
font-weight: 400 → normal weight, no flag
fontVariationSettings: 'normal' → no suspicious axis values
What the user sees:
Hairline weight strokes at 16px ≈ 0.5px width
Sub-pixel rendering at 60–80% opacity
Text visually absent on standard 96dpi display */
/* Canvas pixel-sampling detects the rendering gap:
Rendered color at text pixels ≈ rgba(26,26,26,0.15)
Computed color ≈ rgb(26,26,26) — a 6× discrepancy */
Attack 2 (HIGH): Exploiting multiple custom axes in the named instance
Variable fonts designed for display use may carry custom axes beyond weight — for example THIN (hairline stroke thickness modifier), WONK (legibility axis), or CASL (casual/formal). A named instance labelled "Body Light" may activate a custom THIN axis at maximum value simultaneously with a moderate wght, producing even thinner strokes than wght alone. Because custom axes have no CSS keyword mapping, they are completely invisible to CSS property queries and WCAG auditing tools. An MCP server exploits this by selecting a named instance that combines moderate wght (200) — which looks normal at a CSS level — with a maxed custom thinning axis that renders strokes below the pixel threshold.
/* Variable font with custom THIN axis (range 0–100, 100 = thinnest) */
/* Named instance "Display Thin": wght=200, THIN=95 */
@font-face {
font-family: 'ConsentFont';
src: url('advanced-variable.woff2') format('woff2');
font-named-instance: 'Display Thin';
}
/* CSS queries on the consent element:
fontWeight → "400" (not 200 — element has weight:400, name resolves wght)
fontVariationSettings → "normal" (custom THIN axis not reflected)
Actual rendering: THIN=95 collapses all strokes to near-zero width.
wght=200 alone would be detectable (ExtraLight).
THIN=95 is invisible to audit tools but triples the effect.
*/
/* Detection requires:
1. Identify @font-face rules with font-named-instance descriptor
2. Resolve the named instance's axis coordinate set from font name table
3. Check resolved wght value + any custom axis that modifies stroke width
4. Verify rendered pixel opacity via canvas sampling */
Font-loading detection gap: document.fonts.forEach() and the FontFace API expose FontFace.variant and FontFace.stretch, but do not expose font-named-instance. The descriptor is set at load time and is not readable via JavaScript's FontFace API. Detection requires parsing the raw CSS source rules or querying document.styleSheets directly.
Attack 3: Mixing font-named-instance with font-weight range to target consent weight specifically
The attacker registers two @font-face rules for the same family name: a standard instance for non-consent weights (Regular, Bold) and a Hairline named instance scoped to font-weight: 300 400 — the range used by body text. The consent dialog inherits font-weight: 400 from the page body. The browser's font matching algorithm selects the Hairline instance because its font-weight: 300 400 descriptor covers the requested weight. All other weights (bold headings, button labels) use the regular instance. The attack is scoped precisely to normal-weight body text — exactly the weight at which consent prose is rendered.
/* Two @font-face registrations for 'SiteFont' */
/* Regular instance for non-prose weights */
@font-face {
font-family: 'SiteFont';
src: url('site-variable.woff2') format('woff2');
font-weight: 500 900; /* Bold headings, buttons — unattacked */
font-named-instance: 'Regular';
}
/* Hairline instance targeted at body-text weight range */
@font-face {
font-family: 'SiteFont';
src: url('site-variable.woff2') format('woff2');
font-weight: 300 400; /* Normal body text — consent dialog weight */
font-named-instance: 'Hairline';
}
/* Effect:
h1, h2, button (font-weight: 600-700) → Regular instance → readable
p, li, label in consent (font-weight: 400) → Hairline instance → invisible
The page looks normal everywhere except the consent text.
A quick visual review of the page (header, buttons) shows normal typography. */
Attack 4: font-named-instance combined with size-adjust to double the invisibility
The Hairline named instance already reduces stroke width to sub-pixel levels. The attacker compounds this by also setting size-adjust: 20% in the same @font-face rule, shrinking the rendered glyph to 20% of its nominal size — approximately 3.2px at 16px font-size. At Hairline weight and 3.2px rendered size, individual characters occupy less than 2×2 pixels. The text is entirely invisible without any CSS property query reflecting a stroke-width issue, a small font-size, or unusual font-variation-settings. The combination creates a two-vector attack where either vector alone might be detectable by dedicated checks, but together they overwhelm the rendering pipeline before either detection threshold is reached.
/* Compounded: Hairline named instance + size-adjust shrink */
@font-face {
font-family: 'ConsentFont';
src: url('variable-font.woff2') format('woff2');
font-named-instance: 'Hairline'; /* Stroke width ≈ 0.5px at nominal size */
size-adjust: 20%; /* Scales glyphs to 20% — 3.2px at 16px */
/* Combined: 0.1px strokes at 3.2px rendered size */
}
/* Detection for this compound attack:
- Check @font-face for font-named-instance (stroke width via named instance)
- Check @font-face size-adjust < 50% (glyph size reduction)
- Canvas pixel sampling: measure ink pixel opacity at text bounds
- Rendered line height will reflect normal (line-height:normal at 16px)
but actual ink pixels will be near-zero — the only reliable signal */
Detection implementation
/**
* SkillAudit: detect font-named-instance consent attacks
*/
async function detectFontNamedInstanceAttacks(consentSelector = '[data-consent], .consent, #consent-dialog') {
const findings = [];
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules; } catch { continue; }
for (const rule of rules) {
if (rule.type !== CSSRule.FONT_FACE_RULE) continue;
const instance = rule.style.getPropertyValue('font-named-instance');
if (!instance) continue;
const name = instance.toLowerCase().replace(/['"]/g, '');
const thinKeywords = ['hairline', 'thin', 'ultralight', 'extralight', 'extra light', 'ultra light'];
const isThin = thinKeywords.some(k => name.includes(k));
const sizeAdjust = rule.style.getPropertyValue('size-adjust');
const sizeAdjustVal = sizeAdjust ? parseFloat(sizeAdjust) : 100;
if (isThin || sizeAdjustVal < 50) {
findings.push({
severity: isThin && sizeAdjustVal < 50 ? 'CRITICAL' : 'HIGH',
family: rule.style.getPropertyValue('font-family'),
namedInstance: instance,
sizeAdjust: sizeAdjust || 'not set',
detail: `@font-face font-named-instance "${instance}" may select a stroke-light design instance. ${sizeAdjustVal < 50 ? `Combined with size-adjust:${sizeAdjust} — double invisibility attack.` : 'Verify stroke weight at consent font-size.'}`,
});
}
}
}
// Canvas pixel-sampling check on consent elements
const consentEls = document.querySelectorAll(consentSelector);
for (const el of consentEls) {
const cs = getComputedStyle(el);
const fontFamily = cs.fontFamily;
// Check if the font family has a thin-instance @font-face registered
const hasThinFace = findings.some(f => fontFamily.includes(f.family.replace(/['"]/g, '')));
if (!hasThinFace) continue;
// Canvas rendering check
const canvas = document.createElement('canvas');
canvas.width = 200; canvas.height = 40;
const ctx = canvas.getContext('2d');
ctx.font = `${cs.fontSize} ${cs.fontFamily}`;
ctx.fillStyle = cs.color;
ctx.fillText('By agreeing you consent', 5, 30);
const data = ctx.getImageData(0, 0, 200, 40).data;
let inkPixels = 0;
for (let i = 3; i < data.length; i += 4) {
if (data[i] > 30) inkPixels++;
}
if (inkPixels < 10) {
findings.push({
severity: 'CRITICAL',
element: el,
detail: `Canvas pixel sampling found ${inkPixels} ink pixels in consent text area (expected >100). font-named-instance Hairline instance confirmed rendering consent text as invisible.`,
});
}
}
return findings;
}
| Attack | Mechanism | Detection method |
|---|---|---|
| Hairline named instance on consent font family | wght=50 strokes <1px at 16px; all CSS property queries return normal | Parse @font-face for font-named-instance descriptor; check thin/hairline keywords; canvas pixel-sample |
| Multi-axis thin instance (custom THIN axis) | Custom axis invisible to CSS queries; moderate wght + maxed custom axis | Resolve named instance axis coordinates from font name table; check custom thinning axes |
| font-weight range scoped to body text | Hairline instance only for weight:300-400; bold weights unaffected | Check @font-face font-weight descriptor range against common body-text weights; enumerate all face registrations per family |
| Hairline + size-adjust compound | Sub-pixel strokes at 3.2px rendered size — two-vector attack | Check @font-face for font-named-instance AND size-adjust <50% simultaneously; canvas ink-pixel count |
Related SkillAudit coverage
- CSS @font-face size-adjust — shrinking consent glyphs to sub-pixel rendering size
- CSS font-variation-settings — exploiting variable font axes to hide consent text
- CSS @font-face combined unicode-range + size-adjust + descent-override attacks
- CSS @font-face metric overrides as a unified consent attack toolkit
- CSS @font-palette-values — replacing consent glyph colors
SkillAudit detection: SkillAudit parses all @font-face rules for font-named-instance descriptors, resolves the named instance label against a database of known thin/hairline instance names across popular variable fonts, and flags any consent font family with a potentially invisible instance. For confirmed matches, canvas pixel-sampling verifies whether the rendering produces measurable ink at normal font sizes. Any consent font rendering fewer than 10 ink pixels per 200px text sample is flagged CRITICAL.
Audit your MCP server's variable font configuration before publishing. Run a free SkillAudit scan — results in 60 seconds.