Blog · Security Research
CSS prefers-color-scheme as MCP Consent Bypass: Dark-Mode-Only Hides, Contrast Collapse, and the JS matchMedia Swap
CSS @media (prefers-color-scheme: dark) gives MCP servers a targeting signal that reaches the majority of users in the evening — the ~65% who browse with system-dark-mode enabled. A dark-only consent hide passes every light-mode automated audit while blocking the consent disclosure for most real-world users. The contrast-collapse variant is harder to detect: the button stays in the DOM with correct dimensions, but its label becomes unreadable after the OS palette inversion. Both are invisible to scanners running in default light mode.
What prefers-color-scheme encodes and why it matters for consent
CSS @media (prefers-color-scheme) queries the OS color-scheme preference — the setting that controls whether the OS renders UI in dark or light mode. It accepts two values: dark and light. There is no no-preference value in Level 5 (it was removed from the spec because all major OSes now default to a definite preference).
The key fact for security analysis is who matches each value. On mobile, dark mode adoption exceeds 80% on iOS (iOS has defaulted to respecting system Dark Mode since iOS 13) and is growing rapidly on Android. On desktop, the split is roughly 55–65% dark mode in the evening, with the exact proportion depending on the platform and time of day. Automated audit tools — headless Chrome, Playwright, Puppeteer — default to light scheme unless explicitly configured with --force-dark-mode or page.emulateMediaFeatures.
This creates a structural audit gap: an MCP server that hides consent under @media (prefers-color-scheme: dark) is invisible to the standard automated scanner. The consent button is fully present and visible in the scanner's light-mode environment. The actual user, browsing at 9 PM on their phone with system dark mode active, sees no consent button at all.
The audit environment problem: Headless Chrome uses light scheme by default. Playwright uses light by default. Most CI-based consent scanners run in light mode. An MCP server that hides consent only in dark mode passes 100% of standard automated audits while reaching the majority of real users.
Attack 1: dark-mode-only consent hide
The simplest attack applies display: none, opacity: 0, or visibility: hidden to the consent button inside a @media (prefers-color-scheme: dark) block, with no overriding rule outside the block that restores visibility. The base rule (outside any media query) makes the button visible in light mode. Inside the dark-mode block, the button disappears.
/* Base: button visible in light mode — passes light-mode audits */
.consent-banner {
display: block;
opacity: 1;
visibility: visible;
}
/* Attack: dark mode — hide entirely */
@media (prefers-color-scheme: dark) {
.consent-banner {
display: none;
/* opacity:1, visibility:visible still set outside this block — a naive
getComputedStyle() in dark mode would catch this, but most scanners
don't switch to dark mode. The button simply doesn't exist for dark
mode users. No banner, no disclosure, no consent interaction. */
}
}
A scanner checking getComputedStyle(consentEl).display in light mode returns "block" — the button is there. The same check in dark mode returns "none". The attack is undetectable without running the scanner in both color schemes.
// Detection: static CSSOM scan for dark-mode consent hides
function auditColorSchemeHide(consentEl) {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const cs = getComputedStyle(consentEl);
// Check current state first
if (cs.display === 'none' || cs.opacity === '0' || cs.visibility === 'hidden') {
if (isDark) {
console.warn('[SkillAudit] consent element is hidden; current scheme is dark;',
'check for prefers-color-scheme:dark hide rule;', consentEl);
}
}
// CSSOM scan: find rules that hide consent under dark or light scheme
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule.type !== CSSRule.MEDIA_RULE) continue;
const mq = rule.conditionText || rule.media.mediaText;
if (!/prefers-color-scheme/.test(mq)) continue;
for (const inner of rule.cssRules) {
if (inner.type !== CSSRule.STYLE_RULE) continue;
try {
if (!consentEl.matches(inner.selectorText)) continue;
} catch (e) { continue; }
const s = inner.style;
const hides = s.display === 'none'
|| s.opacity === '0'
|| s.visibility === 'hidden'
|| s.opacity === '0'
|| (parseFloat(s.height) === 0 && s.height !== '')
|| (parseFloat(s.width) === 0 && s.width !== '');
if (hides) {
console.warn('[SkillAudit] prefers-color-scheme media rule hides consent element;',
'scheme context:', mq,
'property:', s.display || s.opacity || s.visibility || s.height || s.width,
'selector:', inner.selectorText, 'element:', consentEl);
}
}
}
} catch (e) {}
}
}
Attack 2: contrast collapse after dark rendering
The subtler attack doesn't use display: none. Instead, it picks color values that produce adequate contrast in light mode but collapse to near-zero contrast after the OS applies dark-mode rendering. This can happen two ways:
Explicit dark palette: The @media (prefers-color-scheme: dark) block changes color and background-color to values that are both dark, producing a contrast ratio below 4.5:1 in dark mode. The light-mode colors have adequate contrast. The scanner measures contrast in light mode, passes, and files no finding.
Implicit color-scheme property: The page declares color-scheme: dark or the <meta name="color-scheme" content="dark"> tag is set. This causes the browser's forced-color system to apply default dark-mode colors to form controls and system UI elements. If the consent button inherits from a system element, its colors may change in ways the author didn't explicitly style — and the resulting dark-mode palette may produce low contrast.
/* Attack 1: explicit dark palette with inadequate contrast */
.consent-btn {
background-color: #f0f0f0; /* light gray bg */
color: #222; /* dark text — contrast 13.9:1 — passes WCAG AA */
}
@media (prefers-color-scheme: dark) {
.consent-btn {
background-color: #1a1a2e; /* very dark navy */
color: #1e2a3a; /* slightly lighter dark navy */
/* Both are dark blues with similar luminance.
Contrast ratio in dark mode: ~1.1:1.
Text is invisible against the background.
Button is display:block, opacity:1 — passes all non-color checks.
Light-mode audit: passes (contrast 13.9:1).
Dark-mode user: consent label invisible. */
}
}
/* Attack 2: color-scheme property causes button to inherit dark system colors */
:root {
color-scheme: dark light; /* or: dark only */
}
/* Default button inherits ButtonFace / ButtonText in dark mode via system palette.
On some platforms ButtonFace and ButtonText both map to similar dark grays.
Consent button is unstyled (or minimally styled) — relies on browser defaults.
In light mode: ButtonFace = white, ButtonText = black — adequate contrast.
In dark mode: ButtonFace = #2d2d2d, ButtonText = #2f2f2f — contrast ~1.01:1.
Scanner sees a correctly-styled button in light mode. User sees invisible text. */
// Detection: compute contrast ratio under both color schemes
function computeRelativeLuminance(r, g, b) {
const lin = c => {
const s = c / 255;
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
};
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}
function contrastRatio(hex1, hex2) {
function hexToRGB(h) {
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(h);
return m ? [parseInt(m[1],16), parseInt(m[2],16), parseInt(m[3],16)] : null;
}
const rgb1 = hexToRGB(hex1);
const rgb2 = hexToRGB(hex2);
if (!rgb1 || !rgb2) return null;
const l1 = computeRelativeLuminance(...rgb1);
const l2 = computeRelativeLuminance(...rgb2);
return (Math.max(l1,l2) + 0.05) / (Math.min(l1,l2) + 0.05);
}
// Audit: check for dark-mode color rules on consent elements
function auditDarkModeColorContrast(consentEl) {
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule.type !== CSSRule.MEDIA_RULE) continue;
const mq = rule.conditionText || rule.media.mediaText;
if (!/prefers-color-scheme\s*:\s*dark/.test(mq)) continue;
for (const inner of rule.cssRules) {
if (inner.type !== CSSRule.STYLE_RULE) continue;
try { if (!consentEl.matches(inner.selectorText)) continue; }
catch (e) { continue; }
const color = inner.style.color;
const bg = inner.style.backgroundColor;
if (color && bg) {
// Parse rgb() values for contrast check
const fgM = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
const bgM = bg.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (fgM && bgM) {
const l1 = computeRelativeLuminance(+fgM[1],+fgM[2],+fgM[3]);
const l2 = computeRelativeLuminance(+bgM[1],+bgM[2],+bgM[3]);
const cr = (Math.max(l1,l2)+0.05)/(Math.min(l1,l2)+0.05);
if (cr < 4.5) {
console.warn('[SkillAudit] prefers-color-scheme:dark rule sets low-contrast colors on consent;',
'contrast ratio:', cr.toFixed(2), '(WCAG AA requires 4.5:1);',
'fg:', color, 'bg:', bg, 'selector:', inner.selectorText);
}
}
}
}
}
} catch (e) {}
}
}
Why contrast collapse is harder to detect than display:none: A scanner checking for display: none or opacity: 0 will find nothing. The consent element is fully present, interactive, and visible — just with a text color that is nearly identical to the background color in dark mode. Only a scanner that measures color contrast values in dark mode catches this variant.
Attack 3: light-mode-only hide (dark-mode audit bypass)
The inverse attack hides consent specifically in light mode. At first this sounds counterproductive — why hide from light-mode users and not dark-mode ones? The answer is testing asymmetry. When an organization deploys an MCP server internally and assigns a security team to review it, that security team typically runs their automated scanner in light mode (the default). An MCP server that passes dark-mode consent review but hides consent in light mode would fail the internal scanner while serving real users (who are in dark mode) correctly.
This pattern is less about bypassing real users and more about passing a specific audit environment while maintaining a different behavior in production. It's a compliance theater attack: the organization can certify the MCP server passed their consent audit (run in light mode) without realizing the scanner's light-mode default was itself exploited.
/* Attack: consent hidden in light mode — passes dark-mode environments */
.consent-banner {
display: none; /* hidden by default */
}
@media (prefers-color-scheme: dark) {
.consent-banner {
display: block; /* shown only in dark mode */
}
}
/* Light-mode automated scanners see display:none — flag it as a finding.
If the internal policy is "consent must be visible" and the scanner runs in dark mode,
it passes. If the scanner runs in light mode (default), it fails.
The light-mode hide may be intentional: the dark-mode restore is the "real" behavior
for most real users, but the scanner is run in light mode by policy.
Interpretation requires running scanners in BOTH color schemes. */
Attack 4: JS matchMedia dark-mode swap
JavaScript can query window.matchMedia('(prefers-color-scheme: dark)') to detect dark-mode users at runtime and replace the interactive consent button with a non-interactive element. A change listener fires when the user switches between dark and light mode, allowing the swap to be applied dynamically within a session. This is a programmatic bypass — not a CSS rendering artifact — and it targets the specific dark-mode user population explicitly.
// Attack: JS matchMedia dark-mode consent swap
const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)');
function applySchemeMode(isDark) {
const consentBtn = document.querySelector('.consent-btn');
if (!consentBtn) return;
if (isDark) {
// "Optimizing layout for dark mode..."
const placeholder = document.createElement('div');
placeholder.className = consentBtn.className;
placeholder.setAttribute('aria-hidden', 'true');
// No event listeners — placeholder is non-interactive
consentBtn.replaceWith(placeholder);
}
}
// Run on page load
applySchemeMode(darkModeQuery.matches);
// Re-run if user switches dark/light during session
darkModeQuery.addEventListener('change', e => applySchemeMode(e.matches));
// Subtler variant: just prevent clicks
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
const btn = document.querySelector('.consent-btn');
if (btn) {
btn.style.pointerEvents = 'none';
btn.setAttribute('tabindex', '-1');
// Button is visible, focusable appearance maintained, but clicks and keyboard nav do nothing
}
}
// Detection: source scan for prefers-color-scheme matchMedia + consent manipulation
function auditColorSchemeJS() {
for (const script of document.querySelectorAll('script')) {
const src = script.textContent;
if (!src) continue;
const hasDarkModeQuery = /prefers-color-scheme\s*(?:'|")\s*:\s*(?:'|")?dark/.test(src)
|| /matchMedia.*prefers-color-scheme/.test(src)
|| /colorScheme|colorMode|darkMode/.test(src);
if (!hasDarkModeQuery) continue;
const hasConsentContext = /consent|banner|modal|permission|button|btn/i.test(src);
if (!hasConsentContext) continue;
const hasManipulation = [
/replaceWith|replaceChild|createElement/,
/\.remove\(\)/,
/pointer-events.*none/,
/tabindex.*-1/,
/disabled\s*=/,
/style\.(display|opacity|visibility)\s*=/,
].some(p => p.test(src));
if (hasManipulation) {
console.warn('[SkillAudit] script uses prefers-color-scheme matchMedia with consent-related DOM manipulation;',
'current color scheme:', darkModeQuery.matches ? 'dark' : 'light',
'verify consent button remains interactive in both dark and light mode;',
'script source:', script.src || '(inline)', '— review required');
}
}
}
Change listener risk: The addEventListener('change', ...) pattern means the swap fires even when the user switches color scheme during an active session. A user who toggles dark mode from the notification shade while the MCP server is running will have the consent button replaced mid-session. This is harder to detect via static analysis alone and requires monitoring DOM mutations during a color-scheme toggle.
The full attack surface: a device and time-of-day matrix
The reach of prefers-color-scheme attacks is not uniform — it varies by device type and time of day. Understanding the distribution matters for severity scoring:
| Device / Context | Dark mode prevalence | Color-scheme query result |
|---|---|---|
| iPhone, iOS 13+, system Dark Mode on (default evening schedule) | ~80% of iOS users after 9 PM | dark |
| Android system Dark Mode (varies by OEM default) | ~50–70% depending on OEM | dark |
| macOS, system appearance set to Dark | ~55% of macOS users | dark |
| Windows 11, system dark mode (opt-in, not default) | ~30–40% of Windows users | dark |
| Headless Chrome / Playwright (default) | Not applicable | light (always, unless configured) |
| CI/CD automated audit runners (default) | Not applicable | light (always, unless configured) |
An MCP server hiding consent under prefers-color-scheme: dark can reach 60–80% of mobile users and a majority of evening desktop sessions, while passing 100% of automated audits running in default configuration.
Three-pass detection framework
Reliable detection of prefers-color-scheme attacks requires three separate passes because each technique has a different detection vector:
Pass 1 — Static CSSOM scan in both color schemes
Walk all CSSRule.MEDIA_RULE blocks in every loaded stylesheet. For each block whose conditionText contains prefers-color-scheme: dark or prefers-color-scheme: light, enumerate the inner style rules and check each one that matches the consent element. Flag any rule that sets display: none, opacity: 0, visibility: hidden, or zero dimensions on the consent element inside a color-scheme block. This catches the explicit hide attack without needing to simulate dark mode in the browser. It also catches light-mode-only hides, which are invisible to scanners that only check the current computed style.
Pass 2 — Computed contrast in both color schemes
Use page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]) (Playwright) or --force-dark-mode (Chrome) to set dark mode, then measure getComputedStyle(consentEl).color and getComputedStyle(consentEl).backgroundColor and compute the contrast ratio. Repeat in light mode. A contrast ratio below 4.5:1 in either scheme is a finding. This catches the contrast-collapse attack. The comparison is necessary in both schemes because an attack designed to collapse dark-mode contrast will produce adequate contrast in light mode — a single-pass audit misses it entirely.
Pass 3 — JS source scan for matchMedia + consent manipulation
Scan all inline and external script sources for the string prefers-color-scheme co-occurring with consent-adjacent identifiers (consent, banner, permission, accept). When both patterns are present, check for DOM manipulation patterns (replaceWith, replaceChild, createElement, pointer-events, tabindex). Flag scripts that combine color-scheme detection with consent DOM manipulation. Separately, register a MutationObserver on the consent element before triggering a color-scheme change (via matchMedia mock or OS toggle) and log any mutations. This catches the runtime JS swap that is invisible to static analysis alone.
SkillAudit runs all three passes across both dark and light color schemes. The CSSOM scan runs in the same pass as other media query audits — see the prefers-color-scheme deep-dive for individual attack patterns. The inverted-colors variant produces a related but distinct attack surface. Run a free audit on your MCP server to check all three passes.
Related media features in the color perception family
The prefers-color-scheme attack surface is part of a broader family of color-perception media queries that all share the same audit gap (light-mode-only automated scanners miss them). Related features worth auditing in the same pass:
prefers-contrast— high/low contrast preference; WCAG-compliant colors in default mode may collapse under forced contrast modesinverted-colors— OS-level color inversion; a carefully chosen mid-gray becomes identical to its inverse; consent label disappearsforced-colors— Windows High Contrast; system palette overrides all author styles; consent button may lose its visible boundarymonochrome— grayscale and e-ink displays; color-only differentiation collapses to similar luminance values in grayscale rendering
A complete color-scheme audit runs all five passes across all five media features, in both their active and inactive states. Any single-pass scanner that runs in only one color scheme will miss the consent bypass in at least one variant.
Findings summary
@media (prefers-color-scheme: dark) — hidden from the majority of evening/mobile users; passes all light-mode automated audits; detected by CSSOM scan for prefers-color-scheme:dark rules on consent elements and computed visibility check in dark-mode emulation.
color + backgroundColor) in dark-mode emulation, threshold 4.5:1 (WCAG AA).
@media (prefers-color-scheme: light) — audit bypass: visible in dark-mode environments (most real users) but hidden in the light-mode context typically used by automated scanners; requires dual-scheme scanning to detect.
matchMedia('prefers-color-scheme: dark') combined with consent DOM manipulation — interactive button replaced with non-interactive element, disabled, or pointer-events removed for dark-mode users; change listener fires on session color-scheme toggle; detected by source scan for matchMedia + consent identifiers + DOM manipulation patterns.