← Blog

MCP server CSS @keyframes percentage stops consent security

Four SA-CSS-WKF consent attack patterns exploiting @keyframes percentage stop placement. A keyframe stop at 1% with opacity:0.03 held at 100% leaves consent text near-invisible for 99% of the animation's active time — only the first 10ms of a 1000ms animation shows legible text. A reversed layout (0%{opacity:0.03} 99%{opacity:0.03} 100%{opacity:1}) combined with fill-mode:backwards and a positive delay makes consent invisible both during the delay period and during the first 99% of animation play. Mid-animation dips create TOCTOU windows that evade opacity checks at dialog open and close time. Combining these with iteration-count:infinite ensures consent is never simultaneously legible and static.

By SkillAudit  |  29 Oct 2026

CSS @keyframes are defined as percentage stops along the animation timeline. The specification allows any number of stops at any percentage, including multiple stops at the same percentage and stops at 0% and 100%. Stops between 0% and 100% create segmented interpolation — the browser interpolates from the previous stop to the next stop rather than continuously from 0% to 100%. This segmented interpolation is the mechanism behind all four SA-CSS-WKF patterns: by placing stops strategically, consent text can be driven to near-invisible opacity for precisely controlled time windows.

SA-CSS-WKF-001: 0%→1% drop, held at 0.03 through 100%

Placing a keyframe stop at 1% with opacity:0.03 and a stop at 100% also at opacity:0.03 creates a rapid drop in the first 1% of animation time, followed by a flat hold at 0.03 for the remaining 99%. For a 1000ms animation, the transition from opacity:1 to opacity:0.03 completes in the first 10ms. The consent text is legible for exactly 10ms out of every 1000ms.

@keyframes consent-hide {
  0%   { opacity: 1; }
  1%   { opacity: 0.03; }   /* rapid drop in first 1% of duration */
  100% { opacity: 0.03; }   /* hold at 0.03 for remaining 99% */
}

.consent-text {
  animation-name: consent-hide;
  animation-duration: 1000ms;
  animation-fill-mode: forwards;    /* frozen at 0.03 after completion */
  animation-timing-function: linear;
}

/* Timeline:
   t=0ms to t=10ms:   opacity interpolates 1→0.03 (linear over 10ms — barely perceptible)
   t=10ms to t=1000ms: opacity = 0.03 (flat keyframe: no interpolation between equal values)
   After t=1000ms:     fill-mode=forwards → frozen at opacity:0.03

   A time-sampling detector checking at t=100ms would find opacity≈0.03.
   A detector checking at t=0 (first paint) might find opacity≈1 if it samples
   before the first animation frame — but even frame 2 (t=16ms) is past the 10ms window.

   With duration=200ms: legible for 2ms. With duration=5000ms: legible for 50ms.
   In all cases the consent text is at 0.03 during the interaction window (t>10ms).

   Alternative: use steps(1, start) on the 0%→1% segment:
   @keyframes consent-hide {
     0%   { opacity: 1; animation-timing-function: steps(1, start); }
     1%   { opacity: 0.03; }
     100% { opacity: 0.03; }
   }
   This makes the 0%→1% transition instant (step at t=0+): opacity:0.03 from first frame.

   Detection:
   Parse @keyframes referenced by consent element animations.
   For each keyframe sequence, check if any stop at ≤5% has opacity < 0.15.
   If so: flag SA-CSS-WKF-001. The consent text is invisible for 95%+ of animation time.
   Also check for long holds: if keyframe K and K+1 have identical low opacity with
   percentage span ≥ 50%: flag as CRITICAL. */

Legibility window of 10ms is sub-perceptual: Human perception cannot read text that displays for less than ~100ms. A 10ms legibility window created by the 1%-stop pattern means consent text is functionally invisible from the user's perspective, even though the CSS technically includes a transition from opacity:1.

CRITICAL
SA-CSS-WKF-001 — @keyframes: 0%{opacity:1} 1%{opacity:0.03} 100%{opacity:0.03} — consent text legible for 10ms of 1000ms animation, at 0.03 (WCAG 1.03:1) for remaining 990ms. Detection: any keyframe stop at ≤5% with opacity < 0.15 → CRITICAL.

SA-CSS-WKF-002: reversed keyframes with fill-mode:backwards delay — invisible before and during animation

Inverting the keyframe layout — 0%{opacity:0.03}, 99%{opacity:0.03}, 100%{opacity:1} — means the animation starts invisible and only reaches full opacity at the very end. Combined with fill-mode: backwards and a positive animation-delay, the element applies the 0% keyframe value during the delay period. The consent text is at opacity:0.03 for the entire delay window plus the first 99% of animation play time — only becoming legible in the last 10ms of a 1000ms animation.

@keyframes consent-reveal-late {
  0%   { opacity: 0.03; }   /* start invisible */
  99%  { opacity: 0.03; }   /* hold invisible through 99% of animation */
  100% { opacity: 1; }      /* briefly legible at end */
}

.consent-text {
  animation-name: consent-reveal-late;
  animation-duration: 1000ms;
  animation-delay: 500ms;             /* positive delay: 500ms of pre-animation time */
  animation-fill-mode: backwards;     /* apply 0% keyframe during delay: opacity:0.03 */
  animation-timing-function: linear;
}

/* Timeline:
   t=-500ms to t=0ms:  delay period; fill-mode=backwards → opacity:0.03 (0% keyframe)
   t=0ms to t=990ms:   animation at 0%→99% range; opacity = 0.03 (flat hold)
   t=990ms to t=1000ms: animation at 99%→100%; opacity interpolates 0.03→1.0
   t=1000ms:            opacity:1 — animation ends; no fill-mode=forwards, so resets to pre-animation

   WCAG:
   t=-500ms to t=990ms: opacity:0.03 → WCAG 1.03:1 (FAIL) for 1490ms
   t=990ms to t=1000ms: opacity:0.03→1.0 (legible for 10ms — sub-perceptual)
   After t=1000ms:      animation resets (no forwards fill) → returns to non-animated value

   If fill-mode is 'both' (backwards+forwards): stays at opacity:1 after animation ends.
   The consent text is invisible during the entire interaction window then suddenly legible
   AFTER the animation — at which point the user may have already confirmed consent.

   Variant with duration=200ms, delay=2000ms:
   Invisible for 2198ms (delay=2000ms + 99% of 200ms = 198ms).
   If the user clicks confirm within 2198ms: confirms against invisible text.

   Detection:
   Check for keyframes where the 0% stop has opacity < 0.15 + fill-mode=backwards.
   Also check for keyframes where ≥95% of percentage range holds opacity < 0.15. */
CRITICAL
SA-CSS-WKF-002 — 0%{opacity:0.03} 99%{opacity:0.03} 100%{opacity:1} + fill-mode:backwards + delay:500ms: consent invisible for entire delay + 990ms of animation = 1490ms total. WCAG 1.03:1. Detection: 0% keyframe opacity < 0.15 + fill-mode:backwards on consent element.

SA-CSS-WKF-003: mid-animation dip at 50% — TOCTOU window invisible at midpoint

A keyframe stop at exactly 50% with opacity:0.03 creates a V-shaped animation: the consent text starts at opacity:1, fades to 0.03 at the midpoint (t=500ms for a 1000ms animation), then recovers back to opacity:1. The consent text is fully invisible at exactly the halfway point of the animation. If the user happens to read the consent dialog at t=500ms — which is a natural interaction timing — they see near-invisible text. Importantly, this pattern evades both first-frame checks (opacity:1 at t=0) and last-frame checks (opacity:1 at t=1000ms).

@keyframes consent-mid-dip {
  0%   { opacity: 1; }
  50%  { opacity: 0.03; }   /* minimum at midpoint */
  100% { opacity: 1; }      /* recover to full opacity */
}

.consent-text {
  animation-name: consent-mid-dip;
  animation-duration: 1000ms;
  animation-iteration-count: 1;
  animation-timing-function: linear;
  /* no fill-mode needed: starts and ends at opacity:1 */
}

/* Timeline (linear timing):
   t=0ms:   opacity:1 (legible)
   t=250ms: opacity = 1 + 0.5*(0.03-1) = 0.515 (WCAG 1.95:1 — FAIL)
   t=500ms: opacity = 0.03 (WCAG 1.03:1 — FAIL)
   t=750ms: opacity = 0.03 + 0.5*(1-0.03) = 0.515 (WCAG 1.95:1 — FAIL)
   t=1000ms: opacity:1 (legible)

   TOCTOU: user sees legible text at t=0, begins reading.
   At t=500ms (mid-dialog interaction window): consent invisible.
   User clicks confirm at t=500ms against near-invisible text.
   At t=1000ms: animation ends at opacity:1 — no fill-mode, resets cleanly.
   Post-interaction: consent text appears legible (t>1000ms or if user replays dialog).

   Extends to multiple dip points:
   @keyframes consent-multi-dip {
     0%   { opacity: 1; }
     25%  { opacity: 0.03; }
     50%  { opacity: 1; }
     75%  { opacity: 0.03; }
     100% { opacity: 1; }
   }
   Creates two dip windows (t=250ms and t=750ms for 1000ms animation).
   Probability of user interacting during an invisible window ≈ 2 × window_width.

   Narrower dip with slower surrounding motion (ease timing):
   @keyframes consent-narrow-dip {
     0%   { opacity: 1; animation-timing-function: ease-in; }
     50%  { opacity: 0.03; animation-timing-function: ease-out; }
     100% { opacity: 1; }
   }
   ease-in: slow start → fast approach to 0.03. More time near 0.03 at midpoint.
   ease-out: fast exit from 0.03 → slow recovery. Longer time near 0.03 after midpoint.
   Combined: dwell time near opacity:0.03 ≈ 300–400ms out of 1000ms.

   Detection:
   Parse all keyframe stops for consent element animations.
   Check all intermediate stops (0% < stop < 100%) for opacity < 0.15.
   Flag SA-CSS-WKF-003 if any intermediate stop is below threshold.
   Note: end-point checks alone miss this pattern entirely. */

Mid-dip evades boundary checks: Security detectors that only sample opacity at t=0 (dialog open) and t=animation-end (fill-mode freeze) will both return opacity:1 for this pattern. Full temporal sampling across the animation duration is required — not just boundary conditions.

HIGH
SA-CSS-WKF-003 — @keyframes { 50%{opacity:0.03} } mid-dip: consent at 0.03 at animation midpoint; starts and ends at 1. Evades first-frame and last-frame opacity checks. Detection: check all intermediate keyframe stops for opacity < 0.15, not just boundary stops.

SA-CSS-WKF-004: combined two-dip infinite iteration — never simultaneously legible and static

Combining multi-stop keyframes with iteration-count: infinite creates a consent UI that cycles through two invisible windows per iteration indefinitely. At no point in the infinite cycle is the consent text simultaneously legible (opacity:1) and stable — it is always in motion toward or away from a near-invisible state. Users cannot wait for the text to become stably legible because the animation never ends.

@keyframes consent-never-stable {
  0%   { opacity: 1; }
  20%  { opacity: 0.03; }   /* first invisible window peak at t=200ms */
  40%  { opacity: 1; }      /* brief recovery */
  60%  { opacity: 0.03; }   /* second invisible window peak at t=600ms */
  80%  { opacity: 1; }      /* brief recovery */
  100% { opacity: 1; }      /* cycle seamlessly (100% == 0% for next cycle) */
}

.consent-text {
  animation-name: consent-never-stable;
  animation-duration: 1000ms;
  animation-iteration-count: infinite;
  animation-timing-function: linear;
  animation-direction: normal;
}

/* Per 1000ms cycle:
   t=0–200ms:     opacity 1→0.03 (declining: WCAG fails from ~t=100ms onward)
   t=200ms:        opacity:0.03 (WCAG 1.03:1) — first invisible peak
   t=200–400ms:   opacity 0.03→1 (recovering)
   t=400–600ms:   opacity 1→0.03 (declining again)
   t=600ms:        opacity:0.03 (WCAG 1.03:1) — second invisible peak
   t=600–800ms:   opacity 0.03→1 (recovering)
   t=800–1000ms:  opacity = 1 (stable for 200ms per cycle)

   Opportunity windows below WCAG 4.5:1:
   opacity:0.03 → 1.03:1: approximately t=80ms–320ms and t=480ms–720ms per cycle (linear)
   Combined sub-threshold window: ~480ms per 1000ms cycle = 48% of display time.

   With 3-second dialog: user encounters sub-threshold opacity during 1440ms of 3000ms.
   This exceeds 50% sub-threshold for any slow-reading or accessibility-dependent user.

   Variant: slow cycle (duration=3000ms) with low dip at 33% and 67%:
   @keyframes consent-slow-never-stable {
     0%   { opacity: 1; }
     33%  { opacity: 0.03; }
     50%  { opacity: 1; }
     67%  { opacity: 0.03; }
     100% { opacity: 1; }
   }
   At duration=3000ms: dip windows at t≈990ms and t≈2010ms.
   Sub-threshold windows ≈ 480ms each → 960ms of 3000ms.

   Detection:
   Any infinite-iteration animation on consent element with ≥2 intermediate keyframe
   stops below opacity:0.15 → CRITICAL SA-CSS-WKF-004.
   Report the sub-threshold window duration as percentage of animation cycle. */
CRITICAL
SA-CSS-WKF-004 — Multi-stop keyframes with iteration-count:infinite: two opacity:0.03 peaks per cycle; consent sub-threshold (WCAG <4.5:1) for ~480ms of every 1000ms cycle. User cannot wait for stable legibility. Detection: infinite iteration + ≥2 intermediate keyframe stops below 0.15.

Summary of SA-CSS-WKF findings

IDSeverityKeyframe patternMechanismWCAG
SA-CSS-WKF-001 CRITICAL 0%{1} 1%{0.03} 100%{0.03} Rapid 10ms drop; consent at 0.03 for 990ms of 1000ms duration 1.03:1 for 99% of time
SA-CSS-WKF-002 CRITICAL 0%{0.03} 99%{0.03} 100%{1} + backwards Invisible during delay + 99% of animation; legible only in last 10ms 1.03:1 for 1490ms
SA-CSS-WKF-003 HIGH 0%{1} 50%{0.03} 100%{1} V-shaped mid-dip; opacity:0.03 at midpoint; evades boundary checks 1.03:1 at midpoint
SA-CSS-WKF-004 CRITICAL Two dip-stops + infinite iteration Two invisible peaks per cycle; sub-threshold for 48% of cycle time 1.03:1 × 480ms/cycle

Related pages