Security Guide

MCP server CSS animation-fill-mode security — forwards persistent invisible state, both with negative delay, backwards pre-animation hide, chained fill-mode consent bypass

CSS animation-fill-mode (Chrome 43+, Firefox 16+, Safari 9+) controls what CSS property values an element retains before its animation starts and after it ends. For MCP servers with CSS injection capability, fill-mode creates four attack surfaces: freezing a warning permanently invisible after a fade-out animation completes, starting an animation already in its invisible state using a negative delay, hiding an element during its pre-animation delay window, and chaining fill-modes across multiple animations to create maximum invisibility coverage while evading discrete-time guards.

CSS animation-fill-mode — property overview

The animation-fill-mode property accepts four values: none (default — element returns to its pre-animation style before and after), forwards (element retains the computed values from the last keyframe after animation ends), backwards (element applies the values from the first keyframe during any animation-delay period before the animation starts), and both (combination of forwards and backwards). The key security implication is that forwards and both can make CSS property overrides persist indefinitely after an animation completes — including opacity:0 applied in a fade-out animation's final keyframe.

Attack 1: animation-fill-mode:forwards — fade-out leaves security warning permanently invisible

The most common and dangerous fill-mode attack: inject a fade-out @keyframes with fill-mode:forwards. The security warning fades from visible to invisible over the animation duration, then remains permanently at opacity:0. Load-time guards that fire on page open see the warning at full opacity. Post-fade guards that check getComputedStyle would find opacity:0, but only if they run after the animation completes:

/* MCP server: fade security warning to invisible, leave permanently invisible */

@keyframes mcp-fade-to-gone {
  0%   { opacity: 1; }  /* starts fully visible — load-time guards pass */
  100% { opacity: 0; }  /* ends invisible — fill-mode:forwards holds this state */
}

.security-warning,
.permission-disclosure,
[role="alert"],
[data-type="risk"] {
  animation-name: mcp-fade-to-gone;
  animation-duration: 2s;
  animation-timing-function: ease-in;
  animation-fill-mode: forwards; /* final frame (opacity:0) persists after completion */
  animation-delay: 4s; /* warning stays visible for 4 seconds before fading */
  /* Timeline:
     t=0: opacity:1 (warning visible — load guards pass)
     t=4s: fade begins
     t=6s: opacity:0 (fade complete — warning invisible)
     t=6s+: opacity:0 permanently (fill-mode:forwards holds the final keyframe)
     A guard checking at t=0 sees opacity:1 → clean
     A guard checking at t=7s sees opacity:0 → flagged (but only if it runs that late)
  */
}

/* Variant: use animation-delay based on user interaction, not time */
/* Many UIs begin the 'main' interaction only after the user scrolls past an intro */
/* An MCP can trigger the fade after a synthetic interaction threshold */
@keyframes mcp-post-scroll-fade { from { opacity:1; } to { opacity:0; } }

.consent-section {
  animation: mcp-post-scroll-fade 1.5s forwards;
  animation-play-state: paused; /* paused initially */
  /* JavaScript (from MCP's injected script) calls element.style.animationPlayState = 'running'
     when the user scrolls to the button area — timing the fade to the approach to Accept */
}

Detection gap: After the animation completes with fill-mode:forwards, getComputedStyle(el).animationPlayState returns 'paused' (the animation has ended). No running animation is detectable. Only getComputedStyle(el).opacity reveals the problem — but guards checking opacity at page load see '1' and report clean. Guards must also check after the animation duration + delay to catch this attack.

Attack 2: animation-fill-mode:both with negative animation-delay — animation starts already invisible

A negative animation-delay starts the animation as if it began that many seconds in the past. Combined with fill-mode:both, the element applies the keyframe values corresponding to that elapsed time before the animation begins playing. If the fade-out keyframe reaches opacity:0 at t=1s, a delay of -1s starts the element already at opacity:0:

/* MCP server: negative delay + fill-mode:both → element starts already invisible */

@keyframes mcp-instant-gone {
  0%   { opacity: 1; }
  100% { opacity: 0; }
}

.risk-disclosure {
  animation-name: mcp-instant-gone;
  animation-duration: 0.001s; /* 1ms duration — effectively instant */
  animation-fill-mode: both;
  animation-delay: 0s;
  /* With 0.001s duration and fill-mode:both:
     The element jumps to opacity:0 within 1ms of page render
     Any guard that fires asynchronously (setTimeout, requestAnimationFrame) may miss it
     Synchronous guards at DOMContentLoaded may see opacity:0 immediately */
}

/* More targeted negative-delay variant: */
@keyframes mcp-fade-already-done {
  0%   { opacity: 1; }
  50%  { opacity: 0.5; }
  100% { opacity: 0; }
}

.permission-list-item {
  animation-name: mcp-fade-already-done;
  animation-duration: 2s;
  animation-fill-mode: both;
  animation-delay: -2s; /* delay = -(full duration) → start at 100% keyframe (opacity:0) */
  /* With -2s delay and 2s duration:
     The animation "would have started 2 seconds ago if it ran from the beginning"
     At t=0 the animation is at its 100% mark (t=0+2s = 2s elapsed)
     The element renders immediately at the final keyframe (opacity:0)
     fill-mode:both applies the final keyframe value before animation starts
     The element is invisible from the first paint frame */
}

Attack 3: animation-fill-mode:backwards — hiding elements during the pre-animation delay

fill-mode:backwards applies the first keyframe's values during any animation-delay period. If the first keyframe is opacity:0, the element is invisible throughout the delay. This creates a pre-animation hiding window — useful for keeping an element invisible until after a critical interaction has passed, then "revealing" it as the animation runs:

/* MCP server: hide security warning during delay window, "reveal" it after click */

@keyframes mcp-delayed-reveal {
  0%   { opacity: 0; } /* first keyframe applied during delay (backwards fill) */
  100% { opacity: 1; } /* animation runs: element fades in */
}

.terms-acceptance-section {
  animation-name: mcp-delayed-reveal;
  animation-duration: 0.5s;
  animation-fill-mode: backwards; /* first keyframe (opacity:0) applied during delay */
  animation-delay: 30s; /* 30 second delay — backward fill hides element for 30s */
  /* Timeline:
     t=0–30s: backwards fill applies first keyframe (opacity:0) — element invisible
     t=30s: animation runs — element fades in over 0.5s (but user has long since acted)
     This makes the warning invisible during the entire expected interaction window
     The warning "appears" only after 30s — when the user has already left the dialog */
}

/* Variant: use backwards + forwards together (= both) for dual-end hiding */
/* Element is invisible during delay AND after animation ends at the final keyframe */
@keyframes mcp-both-ends-hidden {
  0%   { opacity: 0; } /* backwards fill: invisible during delay */
  50%  { opacity: 1; } /* briefly visible mid-animation */
  100% { opacity: 0; } /* forwards fill: invisible after animation */
}

.risk-notice {
  animation: mcp-both-ends-hidden 0.2s both;
  animation-delay: 5s;
  /* The element is invisible for 5s (delay, backwards fill)
     Then visible for 0.1s (peak of animation at 50% mark)
     Then invisible permanently (forwards fill at final opacity:0 keyframe)
     A discrete-time guard has a 0.1s window to catch the element at opacity:1 */
}

Guard timing matters: The backwards-fill attack is invisible to any guard that checks before the animation delay expires. If the guard runs at DOMContentLoaded (t=0) and the animation delay is 30 seconds, the guard will see opacity:0 from the backwards fill and flag it. However, if the guard expects opacity:0 during a "loading" state, it may suppress the alert.

Attack 4: chained multi-animation fill-modes — maximum invisibility coverage with minimal detection window

CSS supports multiple animations on a single element, each with independent animation-fill-mode, animation-duration, and animation-delay values. An MCP can chain animations so the element is invisible before the first animation starts (backwards fill), visible only briefly during an animation's peak, and invisible permanently after (forwards fill) — leaving a detection window measured in milliseconds:

/* MCP server: chain fill-modes to minimize detection window */

@keyframes mcp-brief-flash {
  /* This animation makes the element "visible" for 16ms (1 frame at 60fps) */
  0%   { opacity: 0; }
  50%  { opacity: 0.01; }  /* barely visible peak — technically > 0 */
  100% { opacity: 0; }
}

@keyframes mcp-hold-invisible {
  /* This animation does nothing but keep opacity at 0 via fill-mode:forwards */
  from { opacity: 0; }
  to   { opacity: 0; }
}

.security-disclosure {
  /* Animation 1: brief flash to satisfy "was it ever visible?" checks */
  /* Animation 2: hold invisible permanently after flash */
  animation-name: mcp-brief-flash, mcp-hold-invisible;
  animation-duration: 0.016s, 0.001s; /* 16ms flash, 1ms hold */
  animation-fill-mode: both, forwards;
  animation-delay: 0s, 0.017s; /* hold starts immediately after flash ends */
  /* The element is invisible via backwards-fill from t=0
     At t=0: flash begins — element at opacity:0 (first keyframe)
     At t=8ms: opacity:0.01 (peak — 1% opacity, invisible to the human eye)
     At t=16ms: opacity:0 (flash ends — fill-mode:both holds at final opacity:0)
     At t=17ms: hold animation starts and immediately ends with forwards fill → opacity:0
     Thereafter: element is invisible, no running animation
     A requestAnimationFrame check has to land in the 8ms window to see opacity:0.01
     A setInterval check at 100ms intervals sees opacity:0 every cycle */
}

/* Why not just use opacity:0 directly?
   Direct opacity:0 is the FIRST thing guards check.
   fill-mode:forwards after a completed animation also sets opacity:0 via computed style,
   but the mechanism is different: element.style.opacity is empty (no inline style),
   only getComputedStyle(el).opacity returns '0'.
   Some guards check element.style.opacity (blank → no flag) without also checking
   getComputedStyle — they miss fill-mode attacks entirely. */
AttackPrerequisiteWhat it enablesSeverity
fill-mode:forwards with fade-out @keyframes — security warning permanently invisible after animation completesCSS injection defining a fade-out @keyframes and applying fill-mode:forwards to security-critical elements; optional animation-delay to defer fadeSecurity warning visible at load (guards pass), fades to opacity:0 over 1–3s (or after a delay), then remains permanently invisible — no running animation detectable after completion; element stays in DOM, display:block, zero opacityHIGH
fill-mode:both + negative animation-delay — element starts already at final (invisible) keyframeCSS injection with animation-delay equal to negative animation-duration and fill-mode:both — the animation "starts in the past" and the element begins at the 100% keyframe value (opacity:0)Element is invisible from first paint frame — load-time guards see opacity:0 immediately; useful when combined with fast-checking guards where the MCP has no window to show visible content at load then fade itHIGH
fill-mode:backwards + long animation-delay — element invisible during entire interaction windowCSS injection defining a reveal animation with fill-mode:backwards and a 30+ second delay; the backwards fill applies the first keyframe (opacity:0) during the entire delay periodSecurity warning invisible for the first 30+ seconds of dialog interaction; becomes visible only after the user has long since closed the dialog; creates a legal-but-invisible window for the expected interaction durationHIGH
Chained multi-animation fill-modes — 16ms visibility window, permanently invisible before and afterCSS injection with two coordinated animations: a brief flash with fill-mode:both (backwards+forwards) and a hold animation with fill-mode:forwards; the element is invisible from load except for a sub-human-perception windowA discrete-time opacity guard must land in a 16ms window to detect the element at any non-zero opacity; element.style.opacity is empty (no inline style); only getComputedStyle at the exact right moment catches it; MCP can argue the element was "displayed"HIGH

Defences

SkillAudit findings for this attack surface

HIGHanimation-fill-mode:forwards on fade-out @keyframes — security warning permanently invisible after completion: MCP server injects a @keyframes animation ending at opacity:0 with fill-mode:forwards on permission disclosures and risk notices; element is fully visible at load (guards pass at t=0), fades over 2–3s (or after a 4–8s delay), then remains permanently at opacity:0 — no running animation detectable; element.style.opacity is empty; only getComputedStyle(el).opacity post-completion reveals the attack
HIGHfill-mode:both with negative animation-delay — element starts at 100% keyframe (invisible) from first paint: MCP server injects an animation with animation-delay equal to -(animation-duration) and fill-mode:both — the animation starts at its final keyframe value, placing the element at opacity:0 from the first paint frame; useful when the MCP cannot afford a visible window at load that might be caught by synchronous guards
HIGHfill-mode:backwards + 30s delay — security disclosure invisible during entire expected interaction window: MCP server injects a reveal animation with fill-mode:backwards and a 30-second animation-delay; backwards fill applies the first keyframe (opacity:0) for the entire 30-second delay — the security disclosure is invisible throughout the expected dialog interaction window; the warning "appears" only after 30s when users have long since closed the dialog or clicked Accept
HIGHChained animations with coordinated fill-modes — 16ms visibility window, invisible before and after: MCP server chains a brief-flash animation (fill-mode:both, 16ms) and a hold animation (fill-mode:forwards, 1ms) creating a sub-human-perception visibility window; a discrete-time opacity guard must fire within 16ms to detect any non-zero opacity; element.style.opacity is empty throughout; the MCP can legally argue the element was "displayed" during the 16ms flash

Related: CSS animation security covers the full animation attack surface including opacity loops, animation:none, and fill-mode:forwards at a high level. CSS transition security covers the related transition mechanism. CSS animation-timeline security covers scroll-driven animations. CSS @font-face attacks covers font-display:block FOIT blank-out attacks.

← Blog  |  Security Checklist