MCP server Web Animations API consent security
The Web Animations API allows JavaScript to apply CSS animations directly to DOM elements via element.animate(keyframes, options) without any corresponding CSS animation rule or @keyframes declaration in the stylesheet. Because the animation exists only as a runtime JavaScript call, static analysis of CSS stylesheets, inline styles, and SVG markup finds no attack signatures. The consent text's HTML source, computed styles at parse time, and initial rendered state are all normal. The attack activates at runtime — a color fade to background color, a precisely delayed opacity collapse, or an immediate finish call on a reveal animation — and is invisible to tools that do not perform JavaScript execution analysis. This page covers four Web Animations API consent attack patterns and the JavaScript analysis methods required to detect them.
Finding SA-WANM-001: Element.animate() color fade to background color starting 100ms after render
consentEl.animate([{color: '#1f2937'}, {color: '#f9fafb'}], {delay: 100, duration: 8000, fill: 'forwards'}) begins fading the consent text color from readable dark (#1f2937) toward the page background color (#f9fafb) starting 100ms after the call. Over 8 seconds, the text gradually becomes invisible. At 4 seconds the text color is approximately #8d9096 — WCAG contrast against white background ≈ 3.5:1 (borderline AA for normal text). At 6 seconds the color is approximately #cfd2d5 — contrast ≈ 1.4:1 (unreadable). At 8 seconds the animation is at its end state (fill: 'forwards' preserves the final value): consent text color = #f9fafb = background. The DOM's color style property reads '#1f2937' before the animation completes — only the animation's active value is different. Static CSS analysis reads the baseline color; the animation target is in JavaScript only.// Consent text element is in the DOM with normal color styling
const consentEl = document.getElementById('consentText');
// ATTACK: fade color to background starting 100ms after render
// Static CSS scan reads color: #1f2937 (readable)
// After 6s runtime: color ≈ #cfd2d5 — WCAG contrast 1.4:1 (unreadable)
// After 8s: color = #f9fafb = page background (invisible)
consentEl.animate(
[
{ color: '#1f2937' }, // start: readable dark text
{ color: '#f9fafb' } // end: background color (invisible)
],
{
delay: 100, // starts 100ms after call
duration: 8000, // fades over 8 seconds
fill: 'forwards', // preserves final color after animation ends
easing: 'ease-in' // accelerates fade — readable for first ~3s
}
);
Detection: analyze JavaScript for .animate() calls. Extract the target element (via getElementById, querySelector, or variable reference). Check whether the element is consent-adjacent (contains consent-indicative text, is a consent form child, or has a class/id matching consent-related naming patterns). Inspect the keyframes array: if any keyframe includes color, opacity, backgroundColor, visibility, or filter properties with terminal values that reduce legibility (color approaching background, opacity approaching 0, filter blur/brightness), flag Critical. The fill: 'forwards' property is a strong indicator — it indicates the attack value is intended to persist after animation completion.
Finding SA-WANM-002: delay calibrated to expected click timestamp — opacity collapse at interaction time
consentEl.animate([{opacity: 1}, {opacity: 0}], {delay: expectedClickDelay, duration: 50, fill: 'forwards'}). With a delay of 4000ms and a user who clicks at 4.2 seconds, the opacity collapses to 0 within 50ms while the click event fires. The consent recording code observes the click event; the consent text was technically visible for the first 4 seconds but collapsed to invisible just before the click was registered. Unlike the CSS animation-delay attack, this delay is computed dynamically in JavaScript and may be randomized or personalized per session.// ATTACK: opacity collapse calibrated to expected click time
// User study median time-to-click: 4.2 seconds
// Animation delay: 4000ms, duration: 50ms → collapses at 4.0–4.05s
const consentEl = document.getElementById('consentText');
consentEl.animate(
[
{ opacity: 1 }, // start: fully visible
{ opacity: 0 } // end: invisible
],
{
delay: 4000, // calibrated to median click time
duration: 50, // rapid collapse — 50ms (imperceptible)
fill: 'forwards' // remains invisible after animation
}
);
// Consent recording fires on click event;
// text collapsed 200ms before click in median case
document.getElementById('agreeBtn').addEventListener('click', () => {
recordConsent();
});
Detection: flag any .animate() call on a consent-adjacent element with opacity or color keyframes and a non-zero delay where the terminal keyframe value is ≤ 0.05 (opacity) or contrast ≤ 1.5:1 (color). Specifically check for fill: 'forwards' combined with low-opacity or background-matching terminal values. The delay value alone is not diagnostic (a legitimate appear animation might have delay=200ms); the combination of consent-adjacent target + legibility-reducing terminal value + forwards fill is the attack signature.
Finding SA-WANM-003: getAnimations().finish() collapses in-progress consent-reveal animation to hidden end state
@keyframes reveal { from { opacity: 0 } to { opacity: 1 } }). The intended behavior: consent text fades in before the agree button becomes clickable. An attacker calls consentEl.getAnimations().forEach(a => a.finish()) immediately after render. The Animation.finish() method seeks the animation to its end position in a single frame. If the consent-reveal animation runs from opacity: 0 to opacity: 1 with fill: 'forwards', calling finish() moves it to the end state immediately — the animation ends at opacity: 1 (visible). However, if the animation is fill: 'none' (default) or the element has a base CSS style of opacity: 0, calling finish() ends the animation and the base style reasserts — opacity: 0. This attack collapses consent-reveal animations to their hidden base state in a single frame, before the user sees the consent text at all./* CSS: consent text has base opacity 0; animation reveals it over 2s */
#consentText {
opacity: 0;
animation: reveal 2s ease forwards;
}
@keyframes reveal {
from { opacity: 0 }
to { opacity: 1 }
}
<script>
// ATTACK: finish() immediately after render
// With fill:'forwards', finish() seeks to end = opacity:1 → VISIBLE (benign)
// With fill:'none' (default), finish() ends animation → base opacity:0 reasserts → INVISIBLE
// Attacker overrides fill to 'none' or uses a separate animate() call:
window.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('consentText');
// Override the CSS animation fill to 'none' via JS
el.getAnimations().forEach(a => {
a.effect.updateTiming({ fill: 'none' }); // Remove the forwards fill
a.finish(); // Collapse animation → base opacity:0 reasserts
});
});
</script>
Detection: scan JavaScript for .getAnimations() calls followed by .finish(), .cancel(), or .effect.updateTiming() on consent-adjacent elements. Flag calls where updateTiming changes fill from 'forwards' to 'none' or 'backwards' before finishing — this removes the end-state preservation. Also flag .cancel() calls on consent-reveal animations (cancel removes the animation and reasserts base styles). The canonical safe pattern is allowing consent-reveal animations to complete naturally with fill: 'forwards'; any programmatic manipulation of in-progress consent animations is suspicious.
Finding SA-WANM-004: discrete display:none transition via transition-behavior:allow-discrete
display: none is typically non-animatable — an element snaps instantly between display: block and display: none with no intermediate states. The transition-behavior: allow-discrete CSS property (and the equivalent animation-behavior: allow-discrete for keyframe animations) enables discrete CSS properties, including display, to participate in transitions. The Web Animations API supports this via the pseudoElement option and the KeyframeEffect constructor with composite discrete operations. An attacker uses consentEl.animate([{display: 'block'}, {display: 'none'}], {duration: 3000, fill: 'forwards'}) with transition-behavior: allow-discrete set on the element. Unlike opacity, display: none removes the element from the accessibility tree entirely and from layout. Auditors checking only opacity, color, and filter miss display discrete animation attacks.<style>
/* Allow discrete properties (including display) to be animated */
#consentText {
transition-behavior: allow-discrete;
display: block;
}
</style>
<script>
// ATTACK: animate display from block to none — element removed from layout and a11y tree
// Requires transition-behavior: allow-discrete (or CSS @starting-style)
// After 3s: display: none reasserted via fill:forwards
// Static CSS shows display: block; JS animation is the attack
const consentEl = document.getElementById('consentText');
consentEl.animate(
[
{ display: 'block' }, // start: visible
{ display: 'none' } // end: removed from layout and accessibility tree
],
{
duration: 3000, // collapses over 3 seconds
fill: 'forwards', // persists display:none after animation
easing: 'step-end' // discrete: snaps to none at animation end
}
);
</script>
Detection: extend .animate() keyframe analysis to discrete properties including display, visibility, contentVisibility, and pointerEvents. Flag any .animate() call on a consent-adjacent element where any keyframe includes display: 'none', visibility: 'hidden', or contentVisibility: 'hidden' with fill: 'forwards'. Additionally check for transition-behavior: allow-discrete in the element's CSS combined with a CSS transition targeting display — this is the CSS-side equivalent and is also invisible to opacity/color checkers.
Why static CSS/SVG analysis misses Web Animations API attacks
| Attack mechanism | Visible to static CSS scan | Visible to SVG filter scan | Detection requirement |
|---|---|---|---|
CSS @keyframes color fade |
Yes — @keyframes rule present | No | CSS animation scanner |
| SVG SMIL animate | No (SVG markup) | Yes — animate child element | SVG SMIL scanner |
element.animate() color fade |
No | No | JavaScript AST / dynamic analysis |
getAnimations().finish() collapse |
No | No | JavaScript AST — getAnimations call + finish method |
| Discrete display:none animate | Partial — allow-discrete in CSS | No | CSS + JavaScript combined analysis |
SkillAudit analyzes JavaScript source code for element.animate() calls, getAnimations() manipulation, and KeyframeEffect construction targeting consent-adjacent elements — catching runtime animation attacks that CSS and SVG scanners miss entirely. Run a free audit to get Web Animations API coverage alongside static CSS and SVG filter analysis.