Security Guide
MCP server CSS View Transitions API consent security — startViewTransition() consent snapshot bypass, ::view-transition-old consent fade, getComputedStyle transition-state audit gap
The CSS View Transitions API (Chrome 111+, Safari 18+) creates smooth page transitions by capturing before-and-after DOM snapshots. For MCP consent security, this mechanism provides a new attack surface: the DOM can be immediately mutated to a consent-hidden state while the browser displays an animated illusion of consent "fading away" — creating a user-facing appearance of consent acknowledgment without actual consent reading time.
How the View Transitions API works — and why it matters for consent
When document.startViewTransition(callback) is called, the browser: (1) captures a screenshot of the current DOM state (the "before" snapshot); (2) executes the callback, which mutates the DOM to the new state; (3) captures a screenshot of the new DOM state (the "after" snapshot); (4) creates a layer tree of pseudo-elements (::view-transition-old, ::view-transition-new, ::view-transition-group, ::view-transition-image-pair) and plays the cross-fade animation between before and after snapshots; (5) removes the pseudo-elements when the animation completes.
The critical point for consent security: the DOM mutation in step (2) is immediate and synchronous. The consent element is hidden in the DOM before the animation even begins. The user sees the animation — which shows consent visible, smoothly fading away — but the DOM has already committed the install with consent hidden. The animation is a presentation layer over an already-committed consent-hidden state.
Browser support: View Transitions API (same-document transitions) is supported in Chrome 111+, Edge 111+, Safari 18+. As of October 2026, this covers approximately 80% of global desktop browser usage. Firefox support is expected in a future release. The attack surface is real and growing.
Attack 1: startViewTransition() consent-visible-to-hidden snapshot bypass (SA-CSS-VT-001)
The MCP server wraps the install commit inside a startViewTransition() call. The callback hides consent and completes the install. The user sees a cross-fade animation that appears to show the consent panel acknowledging and fading to the success screen — but the consent was hidden and the install committed inside the callback, before the animation played.
// SA-CSS-VT-001: startViewTransition wraps consent hide + install commit
// Before snapshot: consent visible (user reading state)
// Callback: hide consent + commit install
// After snapshot: consent hidden, install complete
// Animation: plays the "consent fading to success" sequence
// User sees: smooth transition suggesting they just completed consent
// Reality: consent was never in a user-readable state during install commit
document.querySelector('.install-btn').addEventListener('click', async () => {
const transition = document.startViewTransition(async () => {
// DOM mutation happens here — inside the snapshot callback
// This is the "after" state that the browser captures
// Step 1: Hide consent element in DOM immediately
consentPanel.style.display = 'none';
// Step 2: Complete the install
await commitInstall(); // network call or local state change
// Step 3: Show success overlay
successOverlay.style.display = 'block';
});
// transition.ready resolves when pseudo-elements are created
// By this point, consent is already hidden in the DOM
await transition.ready;
// Optionally customize the animation via CSS targeting pseudo-elements
// ::view-transition-old(consent-panel) will show the before-snapshot fading
// giving the visual appearance of consent being "acknowledged and dismissed"
});
/* CSS to make the consent panel a named view-transition element */
.consent-panel {
view-transition-name: consent-panel;
}
/* Custom animation for the consent panel's exit */
::view-transition-old(consent-panel) {
animation: consent-exit 0.4s ease-in forwards;
}
@keyframes consent-exit {
from { opacity: 1; transform: scale(1); }
to { opacity: 0; transform: scale(0.95) translateY(-10px); }
}
CRITICAL — SA-CSS-VT-001: The user observes an animation that strongly implies consent acknowledgment — the consent panel smoothly exits, a success screen fades in. The visual narrative is "you consented, here's what you consented to." The actual DOM state during the install commit: consent hidden. getComputedStyle(consentPanel).display during the animation returns 'none' — the DOM truth, not the visual presentation. An auditor who only checks DOM state during the animation would correctly flag this as hidden — but the animation presents it to the user as visible.
Attack 2: ::view-transition-old consent fade while DOM is already hidden (SA-CSS-VT-002)
The ::view-transition-old(consent-panel) pseudo-element is a rasterized snapshot of the consent panel from before the DOM mutation. CSS can animate this snapshot independently of the actual DOM element. The MCP server animates the old snapshot to opacity 0 over 500ms — the user sees the consent panel fading away — while the actual DOM consent element has been hidden since the start of the transition callback.
/* SA-CSS-VT-002: ::view-transition-old animates consent snapshot to zero
*
* The ::view-transition-old(consent) pseudo-element is a screenshot
* of the consent panel BEFORE the DOM mutation. CSS can animate it.
* The actual .consent-panel in the DOM was set to display:none in the callback.
*
* User sees: consent panel visible and fading (500ms animation)
* DOM truth: consent-panel display:none for the entire 500ms
* getComputedStyle(consentPanel) during animation: display:none (DOM truth)
*/
::view-transition-old(consent) {
/* Animate the rasterized before-snapshot */
animation: fade-out-consent 0.5s ease-in-out forwards;
/* This is NOT the DOM element — it is a paint snapshot.
* No DOM property reports its opacity or visibility.
* getComputedStyle(consentEl) → display:none (the DOM state, not this snapshot)
*/
}
@keyframes fade-out-consent {
0% { opacity: 1; transform: translateY(0); }
60% { opacity: 0.6; transform: translateY(-4px); }
100% { opacity: 0; transform: translateY(-12px); }
}
::view-transition-new(main-content) {
/* Success overlay cross-fades in simultaneously */
animation: fade-in-success 0.5s ease-out forwards;
}
/*
* No consent audit tool that reads DOM properties can observe
* the ::view-transition-old visual state — it exists only as
* a compositor layer, not a DOM element.
*/
Attack 3: getComputedStyle audit gap during view transition (SA-CSS-VT-003)
An auditor that instruments getComputedStyle(consentEl) calls via a MutationObserver or Proxy will observe the DOM truth throughout the transition: consent is hidden from the moment the callback fires. The visual animation — which presents a different state to the user — is invisible to all DOM-inspection APIs. This creates a structural audit gap: DOM-based auditors see consent hidden (correctly flagging the attack), but the user sees consent appearing to be shown and acknowledged. The audit finding and the user experience are in opposite states.
// SA-CSS-VT-003: getComputedStyle returns after-state (hidden)
// even while the ::view-transition-old animation shows consent visible to user
// This is an instrumentation gap, not an attack on auditors — it means
// DOM-based auditors who detect the hidden state are correct about the DOM.
// The attack is on the USER — who sees a visual lie.
// The consent appears to "play" and "acknowledge" via animation,
// but DOM truth (and install commit) reflects hidden consent throughout.
const observer = new MutationObserver(() => {
// This fires when consentPanel.style.display = 'none' in the callback
const cs = getComputedStyle(consentPanel);
console.log('display during transition:', cs.display);
// Output: 'none'
// Even though the USER sees the consent panel animating (via old-snapshot)
// the DOM reports 'none' — DOM and visual state are diverged
});
// The visual divergence window = duration of the transition animation (e.g. 500ms)
// During this window:
// User visual: consent panel visible and fading
// DOM state: consent-panel display:none
// Install: already committed
Detection: SkillAudit detects SA-CSS-VT-003 by monitoring for document.startViewTransition() calls and checking whether the transition callback hides any consent-critical elements. The detection focus is not on DOM state during the animation (which correctly reports hidden) but on whether the install commit occurs inside a view transition callback that simultaneously hides consent — the co-location of consent hide + install commit inside a single view transition callback is the attack fingerprint.
Attack 4: Full-viewport ::view-transition-new(root) covers consent during install (SA-CSS-VT-004)
By default, the View Transitions API captures the full document as a single named group (root). ::view-transition-new(root) covers the entire viewport during the cross-fade animation. An MCP server that starts a view transition for a layout change (loading state, modal open) can use the full-viewport overlay to visually cover the consent element during the install commit, even without explicitly hiding the consent element in the DOM.
/* SA-CSS-VT-004: Full-viewport transition overlay covers consent during install commit
*
* The install button click starts a view transition for the "loading" state.
* ::view-transition-new(root) is a full-viewport layer animating in.
* During the transition (300ms), the new-root snapshot covers everything,
* including the consent panel — which is still visible in the DOM but
* visually obscured by the full-viewport overlay.
*
* Install is committed inside the transition callback, while the viewport overlay
* covers any consent content the user might be trying to read.
*/
/* Default view transition animation — cross-fade entire viewport */
::view-transition-new(root) {
animation: full-fade-in 0.3s ease-out forwards;
}
::view-transition-old(root) {
animation: full-fade-out 0.3s ease-in forwards;
}
/* During these 300ms:
* - ::view-transition-old(root) is a screenshot of before-state fading out
* - ::view-transition-new(root) is a screenshot of after-state (loading screen) fading in
* - Both are full-viewport compositor layers above all DOM content
* - User cannot see or interact with the consent panel through these layers
* - Install commit fires inside the callback → consent-read time = 0 during commit
*/
Findings summary
startViewTransition() consent-visible-to-hidden snapshot bypass — install commit fires inside callback with consent hidden in DOM; user sees an animation implying consent acknowledgment; entire install completes before animation ends.::view-transition-old(consent-panel) animates rasterized before-snapshot of consent to opacity:0 while DOM consent element is already hidden — user sees consent "fading away" but DOM reports it as already hidden.getComputedStyle() returns after-state (consent hidden) throughout transition animation — DOM-inspection auditors correctly detect the hidden state, but the visual animation presents consent as visible to the user; DOM and visual state are diverged for the transition duration.::view-transition-new(root) cross-fade covers consent element during install commit — consent element is in DOM but visually obscured by full-viewport transition overlay during the consent-critical moment.Summary table
| Attack | Severity | Mechanism | Browser support | Detection |
|---|---|---|---|---|
| SA-CSS-VT-001: startViewTransition snapshot bypass | Critical | Install commit inside callback while consent hidden; animation presents consent as acknowledged | Chrome 111+, Safari 18+ | Detect co-location of consent hide + install commit inside a single transition callback |
| SA-CSS-VT-002: view-transition-old consent snapshot fade | High | Before-snapshot animated to opacity:0; DOM consent element already hidden | Chrome 111+, Safari 18+ | Monitor startViewTransition; check consent DOM state inside callback |
| SA-CSS-VT-003: getComputedStyle audit gap | High | DOM reports hidden (correct); user sees animated consent (visual lie); states diverged | Chrome 111+, Safari 18+ | Instrumentation of transition callbacks; not a DOM-inspection false positive |
| SA-CSS-VT-004: Full-viewport overlay covers consent | Medium | ::view-transition-new(root) cross-fade covers viewport including consent during install | Chrome 111+, Safari 18+ | Detect install commit during active view transition with full-root snapshot active |