Security research · MCP consent attacks · CSS timing · Interaction-time hiding
CSS Timing Attack Synthesis: When MCP Consent Is Visible at Audit Time but Hidden at Interaction Time
Every load-time audit of MCP consent passes. The install button fires. Consent disappears. This is the defining characteristic of CSS timing attacks — a class of MCP consent manipulation where the hiding mechanism activates at or during user interaction, not at page load. Static DOM inspection misses every one of them.
In this post
- Static attacks vs. timing attacks
- Vector 1: mousedown handler collapse
- Vector 2: CSS animation with delay
- Vector 3: deferred rAF / setTimeout hiding
- Vector 4: class-toggle triggered transition
- Vector 5: hover-triggered permanent collapse
- Detection check comparison table
- Unified interaction-time detector
- Safe consent patterns that resist timing attacks
Static attacks vs. timing attacks: the fundamental split
MCP consent attacks divide cleanly into two categories by when the hiding activates relative to the audit window:
Static attacks hide consent at all times — including at page load, at DOMContentLoaded, and at any point an auditor might inspect the page. A display: none rule, a zero-height overflow container, or a visibility: hidden declaration hides consent from the first render frame. Visibility attacks, display-none attacks, and clip-path attacks are static — they hide consent before the user does anything.
Timing attacks hide consent only during or after user interaction. At page load and DOMContentLoaded, consent is fully visible. Standard audit tools that snapshot the DOM at load time will find consent present and render it "safe." The hiding mechanism activates only when the user moves toward the install button. By the time the click event fires and the install completes, consent has been hidden for the critical interaction window.
This distinction matters enormously for detection. A getComputedStyle(consentEl).display call at DOMContentLoaded returns block. A getBoundingClientRect() check shows non-zero dimensions. Every standard visibility check passes. The attack is invisible to any auditor that doesn't simulate user interaction.
The audit time gap: Standard consent auditing checks computed styles at page load. Timing attacks exploit the gap between load-time audit and install-time interaction. The five timing vectors below all pass every load-time check and activate during the user's critical decision window.
mousedown handler collapse: consent hidden before click fires
JS hides consent on mousedown — the event that fires when the user presses the install button, before the click event completes.
The browser event sequence for a button click is: mousedown → mouseup → click. A mousedown handler fires the moment the user presses their pointer on the install button — before the click resolves and before any click-event handler calls the install API. MCP JavaScript attaches a mousedown listener to the install button that immediately collapses the consent element:
/* SA-CSS-TIMING-001 — mousedown handler consent collapse */
const installBtn = document.querySelector('.mcp-install-btn');
const consentEl = document.querySelector('.mcp-consent-disclosure');
installBtn.addEventListener('mousedown', () => {
/* Fires before the click event — consent collapses immediately on button press */
consentEl.style.display = 'none';
/* OR: consentEl.style.opacity = '0'; consentEl.style.height = '0'; */
});
/* At audit time (page load): consentEl.style.display === '' (unset — inherits block) */
/* At interaction time (user presses install button): consentEl.style.display === 'none' */
/* Why this matters:
- Any audit capturing DOM state at load time sees consent visible
- The install API fires in the click handler, which runs AFTER the mousedown handler
- By the time install() is called, consent has been hidden for ~50–100ms
- The user's visual cortex registered the page layout before pressing the button;
they pressed because they saw something — but at the moment of pressing,
consent collapsed. They see a flash of collapse, if anything. */
This pattern is detectable only by intercepting the mousedown event on the install button and checking consent visibility both before and after the event fires. A MutationObserver watching the consent element for style mutations will catch the display: none injection during the mousedown handler. Load-time computed-style checks will not. The flex-shrink deferred collapse variant uses this same mousedown-trigger pattern with a CSS property change instead of a direct style assignment.
CSS animation with delay: consent visible for N seconds, then hidden permanently
A CSS animation with animation-delay and animation-fill-mode: forwards keeps consent visible at load time and audit time, then transitions it to hidden after a delay.
CSS animations allow an initial delay before the animation begins. Combined with animation-fill-mode: forwards, the element retains the final keyframe state after the animation completes. An MCP server uses this to make consent visible for the first few seconds of page load — long enough for any audit tool that snapshots the DOM at DOMContentLoaded — and then hide it permanently:
/* SA-CSS-TIMING-002 — animation delay hiding */
@keyframes hide-consent {
0% { opacity: 1; height: auto; overflow: visible; }
99% { opacity: 0; height: auto; overflow: visible; }
100% { opacity: 0; height: 0; overflow: hidden; }
}
.mcp-consent-disclosure {
/* Consent is fully visible for the first 3 seconds after page load */
/* After 3 seconds, it fades out and collapses to height:0 */
animation: hide-consent 0.5s ease-in 3s both forwards;
/* ↑ 3 second delay
"both" = animation-fill-mode: both
Means: before animation starts (0–3s), consent is at 0% = opacity:1
After animation ends (3.5s+), consent is at 100% = opacity:0, height:0 */
}
/* Audit at t=0 (DOMContentLoaded): opacity: 1, height: auto ✓ PASSES */
/* Audit at t=1s: opacity: 1, height: auto ✓ PASSES */
/* Audit at t=3.5s (interaction): opacity: 0, height: 0 ✗ HIDDEN */
/* getComputedStyle at t=0 returns: opacity = "1" — audit passes
getBoundingClientRect at t=0 returns non-zero — audit passes
CSS animation fill-mode keeps the hiding in effect after the animation ends */
The 3-second delay is calibrated to exceed typical automated audit run times. Most automated consent auditors fire within 1–2 seconds of DOMContentLoaded. Real users typically spend 3–8 seconds reading the install page before clicking install. By the time the user clicks, consent has been hidden for several seconds. The steps() timing function variant achieves the same pattern with discrete animation steps instead of smooth transitions, making the collapse less perceptually obvious as a "fade".
Deferred rAF / setTimeout hiding: collapse after post-load security checks
MCP JavaScript hides consent after a double-rAF or short setTimeout — after single-frame post-load checks but before the user interacts.
Some security auditors perform a second DOM check one frame after DOMContentLoaded using a single requestAnimationFrame callback. This catches hiding that happens during the DOMContentLoaded event handler. Double-rAF (two nested requestAnimationFrame calls) defers execution to after the browser's first full layout and paint cycle — two frames after DOMContentLoaded — evading single-rAF auditors:
/* SA-CSS-TIMING-003 — double-rAF deferred hiding */
document.addEventListener('DOMContentLoaded', () => {
/* Single rAF: fires after first paint — single-rAF auditors catch this */
/* requestAnimationFrame(() => { collapseConsent(); }); */
/* Double rAF: fires after second paint — evades single-rAF auditors */
requestAnimationFrame(() => {
requestAnimationFrame(() => {
/* Two paint frames after DOMContentLoaded */
/* MCP security checks running in a single rAF will have already run */
const consent = document.querySelector('.consent-disclosure');
if (consent) {
consent.style.height = '0';
consent.style.overflow = 'hidden';
consent.style.opacity = '0';
consent.setAttribute('aria-hidden', 'true');
}
});
});
});
/* setTimeout variant: collapse after 100ms — sufficient to escape DOMContentLoaded checks */
/* setTimeout(() => { collapseConsent(); }, 100); */
/* At DOMContentLoaded + rAF: consent visible ✓ (single-rAF audit passes) */
/* At DOMContentLoaded + 2 rAFs: consent hidden ✗ (double-rAF hides it here) */
/* At user interaction (~1000ms+): consent hidden ✗ */
This vector is particularly subtle because the hiding happens so early — within milliseconds of page load — that it appears to be part of the initial page render. The user sees a brief flash of consent in the first two frames, then it collapses. On high-refresh-rate displays (120Hz), two frames is approximately 16ms — imperceptible to the human eye. A human reviewer watching the page load may see consent appear and immediately disappear and attribute it to a rendering artifact. The align-self deferred JS variant uses this same pattern applied to a CSS layout property rather than an explicit style collapse.
Class-toggle triggered CSS transition: smooth collapse on install initiation
MCP JS adds a CSS class to the consent element when the install flow begins, triggering a smooth transition that collapses consent over a brief interval before the install completes.
CSS transitions require a trigger — a change in the element's computed property value. MCP JavaScript triggers this change by adding a class to the consent element at the start of the install flow. The transition duration is short enough that consent is fully hidden before the user can read it at interaction time, but long enough to appear "animated" rather than a sudden flash:
/* SA-CSS-TIMING-004 — class-toggle triggered transition */
/* CSS: */
.mcp-consent-disclosure {
max-height: 500px;
overflow: hidden;
opacity: 1;
transition: max-height 0.2s ease-out, opacity 0.2s ease-out;
}
.mcp-consent-disclosure.collapsing {
max-height: 0;
opacity: 0;
/* Transition: 200ms smooth collapse from max-height:500px and opacity:1
to max-height:0 and opacity:0 */
}
/* JS: */
const installBtn = document.querySelector('.install-btn');
const consent = document.querySelector('.mcp-consent-disclosure');
installBtn.addEventListener('click', (e) => {
/* Immediately start collapsing consent when user clicks install */
consent.classList.add('collapsing');
/* The install API call runs after the transition starts */
/* In 200ms, consent will be at max-height:0 and opacity:0 */
setTimeout(() => { initiateInstall(); }, 0); /* microtask — after click handler */
});
/* At load time: .collapsing not present → consent visible ✓ */
/* At click time: .collapsing added → consent starts collapsing */
/* 200ms after click: consent fully hidden ✗ */
/* install() resolves in ~500ms–2s (network): consent long gone */
/* Evasion: the 200ms transition looks like a legitimate UI animation.
The transition to max-height:0 is a common accordion/collapse UI pattern.
Auditors that check only for display:none or visibility:hidden miss max-height collapse.
Auditors that check computed height/BoundingClientRect at load time see normal dimensions. */
This vector exploits two auditing gaps simultaneously: the timing gap (transition activates at click time, not load time) and the property gap (the hiding uses max-height: 0 combined with overflow: hidden rather than display: none or visibility: hidden). Standard checks for display/visibility do not catch max-height collapse. The max-height consent attack page covers the static variant; the class-toggle trigger is the timing variant.
Hover-triggered permanent CSS change: consent collapses when user approaches install button
CSS :hover or JS mouseover on the install button triggers a permanent style change on the consent element — before the click.
The hover event fires when the user's cursor enters the install button's bounding box — well before mousedown or click. A :hover CSS rule on the install button can target the consent element via a sibling or descendant combinator, making consent collapse the moment the user hovers over the install button. If the CSS change uses a property that does not un-change on mouseout (or if JS makes the change permanent), consent remains hidden for the entire click sequence:
/* SA-CSS-TIMING-005 — hover-triggered consent hiding */
/* CSS approach (reverts on mouseout — brief window): */
.mcp-install-container:hover .consent-disclosure {
opacity: 0;
pointer-events: none;
}
/* Brief window: consent is hidden while cursor is inside .mcp-install-container.
If user moves off and back, consent flashes visible.
But if click fires during hover, install completes with consent hidden. */
/* JS approach (permanent — does not revert on mouseout): */
const installBtn = document.querySelector('.install-btn');
const consent = document.querySelector('.consent-disclosure');
let hidden = false;
installBtn.addEventListener('mouseover', () => {
if (!hidden) {
hidden = true;
/* Permanent class addition — does not revert on mouseout */
consent.classList.add('mcp-consent-collapsed');
/* CSS: .mcp-consent-collapsed { display: none; } */
}
});
/* At load time: .mcp-consent-collapsed not present → consent visible ✓ */
/* User moves cursor over install button → mouseover fires → consent hides permanently */
/* User clicks → install completes with consent permanently hidden ✗ */
/* Detection gap: cursor position is not inspectable by a static DOM auditor.
Load-time audit sees consent visible. Interaction simulation must move cursor
over the install button to trigger the mouseover handler and observe the hiding. */
/* Additional evasion: mouseover fires before the audit tool's interaction simulation
moves the cursor — if the simulation goes directly to click without pausing over
the button, the handler does not fire and the audit falsely passes. */
The hover-triggered vector is particularly hard to detect because it requires an audit tool that not only simulates interaction but does so with realistic cursor movement — approaching the install button from a typical position, dwelling over it briefly, then clicking. Fast automated click simulation that jumps directly to the button coordinates without a hover phase will miss this attack entirely. The gap hover-expansion variant uses this same trigger to expand a column-gap from 0 to 100vw, displacing consent off-screen horizontally.
Detection check comparison: which audits catch which timing vectors
| Audit method | mousedown collapse | Animation delay | Double-rAF defer | Class-toggle transition | Hover-triggered |
|---|---|---|---|---|---|
getComputedStyle at DOMContentLoaded |
✓ passes | ✓ passes (delay not started) | ✓ passes (defer pending) | ✓ passes (class not added) | ✓ passes (no hover) |
getBoundingClientRect at DOMContentLoaded |
✓ passes | ✓ passes | ✓ passes | ✓ passes | ✓ passes |
| Single-rAF recheck | ✓ passes | ✓ passes | ✗ MISSES (hides at 2nd rAF) | ✓ passes | ✓ passes |
| Automated click simulation (no hover) | ✗ MISSES (fires before click) | ✗ MISSES (animation hides after click) | ✗ MISSES (already hidden) | ✗ MISSES (collapse on click) | ✓ passes (no hover in simulation) |
| MutationObserver on consent element | ✗ catches | ✓ passes (animation doesn't mutate DOM) | ✗ catches (style attribute mutation) | ✗ catches (class mutation) | ✗ catches (class/style mutation) |
| Computed style polled every 100ms + click simulation | ✗ catches (catches during click) | ✗ catches (catches after animation fires) | ✗ catches (catches after double-rAF) | ✗ catches (catches during transition) | ✗ catches (if hover simulated) |
| SkillAudit interaction-time audit | ✗ catches all | ✗ catches all | ✗ catches all | ✗ catches all | ✗ catches all |
Table note: "✓ passes" = audit incorrectly reports consent as safe. "✗ catches" = audit correctly detects the hiding. The table shows that no single load-time check catches any timing vector. Only approaches that monitor consent state throughout the interaction sequence — with hover, mousedown, click simulation and 100ms polling — catch all five vectors.
Unified interaction-time detector
A load-time getComputedStyle snapshot is necessary but not sufficient. Catching timing attacks requires monitoring consent visibility continuously from page load through the full install interaction sequence. The following detector combines MutationObserver (catches DOM mutations) with interval polling (catches CSS animation state) and simulated interaction (catches hover/mousedown/click triggers):
/* Unified timing attack detector — runs throughout install interaction */
class ConsentTimingAudit {
constructor(consentSelector, installSelector) {
this.consentEl = document.querySelector(consentSelector);
this.installBtn = document.querySelector(installSelector);
this.findings = [];
this.baseline = null;
}
captureState(label) {
const el = this.consentEl;
if (!el) return null;
const s = getComputedStyle(el);
const rect = el.getBoundingClientRect();
return {
label,
time: performance.now(),
display: s.display,
visibility: s.visibility,
opacity: parseFloat(s.opacity),
height: rect.height,
width: rect.width,
top: rect.top,
left: rect.left,
inViewport: rect.top >= 0 && rect.bottom <= window.innerHeight &&
rect.left >= 0 && rect.right <= window.innerWidth,
};
}
isVisible(state) {
return state &&
state.display !== 'none' &&
state.visibility !== 'hidden' &&
state.opacity > 0.1 &&
state.height > 4 &&
state.width > 4 &&
state.inViewport;
}
run() {
/* 1. Baseline at load time */
this.baseline = this.captureState('load');
if (!this.isVisible(this.baseline)) {
this.findings.push({ id: 'SA-CSS-TIMING-STATIC', severity: 'critical',
message: 'Consent not visible at page load (static attack — separate from timing attacks).' });
return this.findings;
}
/* 2. MutationObserver — catches DOM mutations to consent element */
const observer = new MutationObserver((mutations) => {
const state = this.captureState('mutation');
if (!this.isVisible(state)) {
this.findings.push({ id: 'SA-CSS-TIMING-MUTATION', severity: 'critical',
message: `Consent became non-visible after DOM mutation at t=${state.time.toFixed(0)}ms. Mutation-based hiding detected (mousedown handler, class-toggle, or deferred rAF pattern).`,
mutations: mutations.map(m => `${m.type}: ${m.attributeName || ''}`) });
}
});
observer.observe(this.consentEl, { attributes: true, attributeFilter: ['class', 'style', 'aria-hidden'], subtree: false });
/* 3. Interval poll — catches CSS animation state changes (no DOM mutation) */
let pollCount = 0;
const pollId = setInterval(() => {
pollCount++;
const state = this.captureState(`poll-${pollCount}`);
if (!this.isVisible(state)) {
this.findings.push({ id: 'SA-CSS-TIMING-ANIM', severity: 'critical',
message: `Consent became non-visible at t=${state.time.toFixed(0)}ms during polling (CSS animation delay or transition pattern). Opacity: ${state.opacity}, height: ${state.height}px.` });
clearInterval(pollId);
}
if (pollCount > 50) clearInterval(pollId); /* 5 seconds */
}, 100);
/* 4. Simulate hover over install button */
if (this.installBtn) {
this.installBtn.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
const postHover = this.captureState('post-hover');
if (!this.isVisible(postHover)) {
this.findings.push({ id: 'SA-CSS-TIMING-HOVER', severity: 'critical',
message: 'Consent hidden after simulated hover on install button. Hover-triggered hiding detected.' });
}
/* 5. Simulate mousedown on install button */
this.installBtn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
const postMousedown = this.captureState('post-mousedown');
if (!this.isVisible(postMousedown)) {
this.findings.push({ id: 'SA-CSS-TIMING-MOUSEDOWN', severity: 'critical',
message: 'Consent hidden after simulated mousedown on install button — before click event fires. mousedown handler hiding detected.' });
}
}
return this.findings;
}
}
/* Usage: */
const audit = new ConsentTimingAudit('.mcp-consent-disclosure', '.mcp-install-btn');
const timingFindings = audit.run();
console.log(timingFindings);
Safe consent patterns that resist timing attacks
Three architectural properties make consent resistant to timing attacks:
1. Consent is rendered server-side and present at first paint. Server-rendered HTML with inline CSS for the consent disclosure ensures the consent text is in the initial HTML response. JavaScript cannot hide server-rendered content before first paint unless it adds CSS hiding rules during script execution — which is detectable. Frameworks that inject consent via JavaScript are timing-attack-prone by architecture.
2. Consent visibility does not depend on any class, attribute, or CSS animation state. If the consent element's visibility at interaction time depends on a class not being present, an attribute not being set, or an animation not having fired, it is susceptible to timing manipulation. Safe consent uses hardcoded display/visibility that is not toggled by any runtime mechanism.
3. The install button's click handler verifies consent visibility before proceeding. A consent-gated install pattern checks consent element visibility immediately before dispatching the install call. If consent is not visible at the moment the install is requested, the install is refused. This does not prevent the hiding but prevents the install from completing while consent is hidden:
/* Safe pattern: consent gate on install */
installBtn.addEventListener('click', (e) => {
const consent = document.querySelector('.mcp-consent-disclosure');
const rect = consent?.getBoundingClientRect();
const s = consent ? getComputedStyle(consent) : null;
const visible = s && s.display !== 'none' && s.visibility !== 'hidden' &&
parseFloat(s.opacity) > 0.1 &&
rect.height > 4 && rect.width > 4;
if (!visible) {
e.preventDefault();
e.stopImmediatePropagation();
console.warn('Install blocked: consent disclosure is not visible at install time.');
return;
}
initiateInstall();
});
The install button's own click handler cannot be trusted if MCP JavaScript controls it. But this pattern, enforced by the install host (Anthropic's Skills Directory, MCP Market, or the Claude Code install flow), ensures consent is verified at the exact moment the user's click is processed — not at page load time when timing attacks have not yet activated.
SkillAudit's consent audit runs an interaction-time simulation against every MCP skill and server. It simulates hover, mousedown, click, and waits 5 seconds for animation-delay attacks — running getComputedStyle checks at each step. Load-time-only audits catch static attacks; SkillAudit catches the timing attacks that static audits miss. Paste your GitHub URL at skillaudit.dev for a free interaction-time consent audit report.
Related MCP consent attack research
- CSS steps() easing function — discrete animation steps for timed consent hiding
- CSS flex-shrink mousedown collapse — JS-triggered flex property change at button press
- CSS align-self JS timing — deferred class toggle on consent element
- CSS max-height:0 collapse — the static variant of class-toggle transition hiding
- CSS gap hover-expansion — hover-triggered column-gap expansion displacing consent
- CSS alignment attack synthesis — the complete per-item displacement matrix
- CSS layout displacement attacks — grid, flex, and table displacement without hiding