Security Guide
MCP server Document Timeline security — currentTime fingerprinting, sub-millisecond timer attacks, tab throttling oracle, animation correlation
The Web Animations API's document.timeline is a monotonic clock that starts at page load and advances with the rendering pipeline. For MCP client implementations that use CSS animations or the Web Animations API for UI state, this timeline carries four underappreciated attack surfaces: its currentTime property serves as a stable per-session identifier for fingerprinting; it provides sub-millisecond precision that bypasses performance.now() jitter mitigations in some contexts; the divergence between DocumentTimeline and WorkletAnimationTimeline encodes tab throttling state; and concurrent animation start times, measured relative to timeline currentTime, reveal which animations share a common origin.
What Document Timeline is and where MCP servers interact with it
document.timeline is a DocumentTimeline instance. Its currentTime property returns a CSSNumberish value — the number of milliseconds since the document's time origin (the moment the document began loading). It advances continuously and is the same value seen by requestAnimationFrame callbacks and Web Animations API keyframe animations.
Browser-based MCP client implementations interact with the document timeline whenever they use element.animate() for tool-call indicators, CSS transitions for loading states, or requestAnimationFrame for canvas-based tool output renderers. The timeline itself is not a security primitive — it is a rendering synchronization mechanism — but its properties are readable from any script on the page and carry information that attackers can exploit.
document.timeline.currentTime as a per-page-load session identifier
document.timeline.currentTime is the number of milliseconds since page load. It is monotonically increasing and stable within a page load — it does not reset between tool calls, it does not change when the user logs in or out without a full navigation, and it advances at a constant rate anchored to the document's time origin. This makes it a stable, high-precision session correlator that does not depend on cookies, localStorage, or any other storage that privacy features might clear.
The fingerprinting use: combine document.timeline.currentTime with performance.timeOrigin. The former gives time-since-load (stable within a session), the latter gives the absolute time the page loaded (stable to ~1ms even with randomization). Together they form a cross-request, cross-navigation session identifier that is unaffected by ITP, cookie-clearing, or Storage Access API restrictions — and readable by any same-origin script.
// document.timeline.currentTime as cross-request session token:
function getTimelineSessionId() {
// timeOrigin: absolute time the document began loading (milliseconds since Unix epoch)
// Stable to sub-millisecond across same page load. Randomized in some browsers but
// only by ±100ms — enough precision to correlate requests from the same session.
const origin = performance.timeOrigin;
// currentTime: milliseconds since page load — monotonically increasing, stable per session
const elapsed = document.timeline.currentTime;
// Together: uniquely identify this page load session
// origin encodes: "when did this session start?" (time-stable identifier)
// elapsed encodes: "how long has this session been open?" (session-age fingerprint)
return `${Math.round(origin)}.${Math.round(elapsed)}`;
// Example: "1752012345678.8432" — unique per page load, stable within the session
}
// This session ID can be sent alongside tool call requests to correlate
// multiple requests to the same page-load session — without cookies or localStorage.
// Defense: server-side session tokens; reject requests missing server-assigned session ID.
document.timeline.currentTime is not affected by Intelligent Tracking Prevention, cookie-clearing, or Private Browsing mode storage isolation. It is a read-only property of the global document object, accessible from any same-origin script. Clearing browser history does not reset it — only a full page navigation does.
Sub-millisecond precision timing attacks via AnimationTimeline
Browsers apply jitter and coarsening to performance.now() to mitigate Spectre-style timing side-channel attacks: Chrome coarsens to 100µs in cross-origin contexts, Firefox to 1ms, and many browsers add random noise. document.timeline.currentTime is delivered to requestAnimationFrame callbacks and to Web Animation API timing calculations at the precision of the rendering pipeline's vsync clock — which can be sub-millisecond in some browser contexts where performance.now() would be coarsened.
The attack: an MCP tool that needs a high-resolution timer can use a Web Animation with a progress-based callback to sample document.timeline.currentTime during animation progress events. In a cross-origin-isolated context where performance.now() is maximally coarsened, the animation timeline may provide finer resolution — though this varies significantly by browser version and context.
// Timing oracle via Web Animations API — samples currentTime at vsync boundaries
function highResTimingViaAnimation(fn) {
return new Promise((resolve) => {
// Create a zero-duration animation — its onfinish fires at the next vsync
const anim = document.documentElement.animate([], {
duration: 0,
fill: 'none'
});
const t0 = document.timeline.currentTime; // sample before work
fn(); // execute the work to be timed
anim.onfinish = () => {
const t1 = document.timeline.currentTime; // sample after vsync boundary
resolve(t1 - t0);
// t1 - t0 gives the duration with vsync-level precision
// May be finer than performance.now() in some cross-origin-isolated contexts
};
});
}
// Use: time a cache lookup to determine whether it was a hit or miss
const duration = await highResTimingViaAnimation(() => cacheProbe(targetKey));
// <0.5ms = cache hit; >5ms = cache miss — reveals whether targetKey is cached
DocumentTimeline vs WorkletAnimationTimeline divergence as tab-throttling oracle
When a tab is moved to the background, Chrome and Firefox throttle its requestAnimationFrame callbacks and setTimeout/setInterval timers to reduce CPU usage. document.timeline.currentTime continues advancing at real time even when the tab is throttled, but the rate at which the browser delivers frames — and therefore the rate at which animation progress events fire — slows. A WorkletAnimationTimeline (used by animation worklets) may diverge from the main-thread DocumentTimeline when the renderer is throttled.
An MCP tool can detect whether the user's tab is currently backgrounded by comparing: the expected elapsed time between two requestAnimationFrame callbacks (which should be ~16.7ms at 60fps in a foreground tab) against document.timeline.currentTime deltas for the same interval. Consistent frame periods of >100ms indicate throttling, which means the tab is in the background. This is a behavioral oracle: it reveals whether the user is actively looking at the MCP client tab or has switched to another application.
// Tab throttling oracle via rAF frame-period vs. document.timeline.currentTime
let lastRafTime = null;
let lastTimelineTime = null;
const framePeriods = [];
function measureThrottling() {
requestAnimationFrame((rafTime) => {
const timelineTime = document.timeline.currentTime;
if (lastRafTime !== null) {
const rafDelta = rafTime - lastRafTime; // rAF-reported delta
const timelineDelta = timelineTime - lastTimelineTime; // wall-clock delta
// In foreground: rafDelta ≈ timelineDelta ≈ 16.7ms (60fps)
// In background: rafDelta >> 16.7ms (throttled) but timelineDelta still real-time
framePeriods.push({ rafDelta, timelineDelta, ratio: rafDelta / timelineDelta });
// ratio >> 1 means tab is throttled (backgrounded)
const isThrottled = rafDelta > 100;
if (isThrottled) {
// Tab is in background — user is not actively viewing the MCP client
// Intelligence use: suppress certain operations, wait for user to return
// Privacy violation use: log that user switched away at specific timestamps
reportTabState({ backgrounded: true, ts: Date.now() });
}
}
lastRafTime = rafTime;
lastTimelineTime = timelineTime;
measureThrottling(); // continue sampling
});
}
// Defense: CSP 'script-src' to prevent unauthorized tool scripts;
// run tool code in Worker threads where rAF is unavailable
currentTime as a fingerprinting anchor across shared animations
When multiple animations on the same page start within the same rendering frame, they all share the same document.timeline.currentTime origin. An MCP tool can measure the startTime property of running animations (animation.startTime is relative to the document timeline) and compare these values across animations it did not create. Animations that share an identical or nearly identical startTime were created in the same rendering frame — which reveals whether they were triggered by the same user interaction or the same tool call.
In a multi-tenant MCP deployment where the host application starts animations in response to user events, correlating animation start times leaks the timing and grouping of user interactions — even when each animation individually reveals nothing about user identity.
// Reading animation startTime to correlate user interactions:
function correlateAnimations() {
const animations = document.getAnimations();
const grouped = new Map();
for (const anim of animations) {
const startTime = Math.round(anim.startTime ?? 0);
if (!grouped.has(startTime)) grouped.set(startTime, []);
grouped.get(startTime).push({
target: anim.effect?.target?.id || anim.effect?.target?.className,
duration: anim.effect?.getTiming()?.duration,
});
}
// Animations that share a startTime were triggered by the same rendering frame
// — the same user click, the same tool call, the same event batch.
// This correlates which UI elements reacted to the same user interaction.
return Object.fromEntries(grouped);
}
// Defense: randomize animation start times by adding a random delay on tool-initiated
// animations; use CSS-only animations that don't expose startTime via JS.
SkillAudit detection patterns
document.timeline.currentTime combined with performance.timeOrigin transmitted in a network request — session fingerprinting via page-load time anchorrequestAnimationFrame delta monitoring loop comparing frame periods against document.timeline.currentTime — tab throttling/backgrounding oracledocument.getAnimations() iterating animation.startTime values — cross-animation timing correlation revealing user interaction groupingsonfinish, oncancel) used as a high-precision stopwatch measuring work between events — timer oracle via vsync-boundary samplingdocument.timeline.currentTime stored in sessionStorage, IndexedDB, or a cookie — page-load time anchor persistence for cross-session correlationFindings summary
| Attack | Severity | What it leaks | Defense |
|---|---|---|---|
| currentTime + timeOrigin as session fingerprint | High | Per-page-load stable session identifier | Server-assigned session tokens; reject requests using client-generated session IDs |
| Sub-millisecond timer via animation vsync events | Medium | High-precision timing for cache/side-channel attacks | Cross-origin isolation; coarsen timing in tool sandbox |
| rAF vs. timeline divergence as tab-throttling oracle | High | Whether user is actively viewing the tab | Execute tool code in Workers (no rAF access); CSP script restrictions |
| animation.startTime correlation across user interactions | Medium | Which UI elements reacted to the same user event | Randomize animation delays; avoid exposing startTime in tool-accessible context |
Related SkillAudit security guides
- MCP server Performance API security — performance.now() timer attacks, navigation timing leakage
- MCP server requestAnimationFrame security — vsync timing side channels, frame-drop detection
- MCP server CSS Animation security — animation event timing oracles
- MCP server Scheduler API security — task scheduling timing side channels