Security Guide

MCP server Background Sync API security — SyncManager enumeration, periodic sync cadence leakage, persistence via sync handlers, cross-origin coordination

The Background Sync API allows service workers to defer network requests until the browser has a stable connection, retrying failed operations even after the initiating page has been closed. For MCP clients that need to guarantee delivery of tool results or audit events under unreliable connectivity, this is a useful reliability primitive. The security risks emerge from what Background Sync exposes to the broader web: pending sync tags are enumerable by any same-origin context via SyncManager.getTags(), periodic sync registration intervals reveal the application's internal behavioral cadence, and service worker sync event handlers execute arbitrary code after the user has navigated away — creating a persistence foothold that survives page close and browser restart.

What the Background Sync API does and where MCP servers use it

The one-off Background Sync API (ServiceWorkerRegistration.sync.register(tag)) queues a named sync event that the browser delivers to the service worker's sync event handler at the next opportunity when the device has network connectivity. The tag is a string identifier for the pending operation. If the browser is offline when the page registers the sync, it queues it and fires the event the next time the device connects — even if the registering page is long gone.

Periodic Background Sync (ServiceWorkerRegistration.periodicSync.register(tag, { minInterval })) extends this to a recurring cadence: the browser fires a periodicsync event no more frequently than minInterval milliseconds, but the actual timing is at the browser's discretion based on site engagement scoring and battery state. The browser may fire more slowly than the minimum interval but never faster.

MCP clients use Background Sync to queue tool call results for upload when the network is unreliable (field deployment scenarios, mobile MCP clients), to guarantee delivery of audit log entries even if the user closes the application mid-session, and to perform lightweight periodic housekeeping such as refreshing tool manifests or checking for MCP server version updates. Each of these use cases requires careful attention to what is encoded in sync tags and how sync handler code is scoped to prevent information leakage and persistence abuse.

Periodic Background Sync requires the periodic-background-sync permission, which Chrome grants based on site engagement score. A site the user visits frequently is more likely to receive the permission. MCP clients embedded in tools the user opens daily may silently gain this capability without explicit user acknowledgment.

SyncManager.getTags() — pending task queue enumeration

registration.sync.getTags() returns a Promise resolving to an array of all currently pending sync tag strings registered by the current origin. Any page, iframe, or worker at the same origin can call this at any time and receive a complete list of all deferred operations waiting to be synced, including those registered by other tabs or by the page's service worker directly.

If sync tags encode information about pending operations — user IDs, session identifiers, operation types, file names being uploaded, or any payload fragment — this function is a live queue inspector with no authentication gate. An attacker with a page open at the same origin (via XSS, a hosted content path, or a subdomain takeover) can enumerate the full queue of pending operations and infer what work the application is deferring.

// VULNERABLE: sync tags encode operation details — attacker can enumerate queue
async function queueToolResultUpload(sessionId, toolName, resultSize) {
  const sw = await navigator.serviceWorker.ready;
  // Tag encodes: who (sessionId), what (toolName), approximate payload size
  // ANY same-origin page can see this via getTags()
  await sw.sync.register(`upload:${sessionId}:${toolName}:${resultSize}bytes`);
}

// ATTACKER CODE at same origin:
async function enumeratePendingUploads() {
  const sw = await navigator.serviceWorker.ready;
  const tags = await sw.sync.getTags();

  // Tags reveal: active sessions, which tools were called, result sizes
  // Example output: ['upload:user-4291:read_file:42836bytes', 'upload:user-4291:execute_cmd:1204bytes']
  return tags.map(tag => {
    const [, sessionId, toolName, size] = tag.match(/^upload:([^:]+):([^:]+):(\d+)bytes$/) || [];
    return { sessionId, toolName, size };
  });
}

// CORRECT: opaque sync tags that reveal nothing about the pending operation
async function queueToolResultUploadSafe(operationId) {
  // operationId is a server-generated UUID — not derived from session/tool/content
  // The actual upload payload lives in IndexedDB keyed by operationId
  const sw = await navigator.serviceWorker.ready;
  await sw.sync.register(`pending-upload:${operationId}`);
  // Service worker looks up operationId in IndexedDB to find the actual payload
  // Tag itself carries no user-identifying or operation-identifying information
}

The correct design is a two-layer architecture: the sync tag is an opaque pointer (a UUID or random token), and the actual operation payload — including all user-identifying and operation-identifying data — lives in IndexedDB keyed by that token. The service worker sync handler reads from IndexedDB using the tag as a key, performs the operation, and deletes the entry on success. This way, getTags() reveals only opaque tokens, not operation details.

Periodic sync cadence as a behavioral fingerprint

The minInterval value passed to periodicSync.register() is an application-level constant that reveals the site's intended polling cadence. An attacker who can inspect registered periodic sync tags via periodicSync.getTags() (which similarly returns all pending periodic sync tags for the origin) can infer the frequency at which the application checks for updates, refreshes data, or performs background maintenance.

Beyond the cadence value itself, the timing of periodic sync events — observable by an attacker page that registers its own periodicsync listener and correlates events with network requests — reveals whether the MCP server is configured for real-time responsiveness (sub-minute intervals) or batch processing (hourly or daily intervals). This fingerprints the MCP server's operational model and can inform more targeted attacks.

// Observable by any same-origin context:
const sw = await navigator.serviceWorker.ready;
const periodicTags = await sw.periodicSync.getTags();
// Returns: ['mcp-manifest-refresh', 'tool-result-flush', 'session-keepalive']
// An attacker can infer: what background operations this MCP client performs,
// how often (from the actual firing intervals they observe), and what they're named

// CORRECT: register periodic sync only when the use case genuinely requires it;
// use opaque tag names; document why each periodic sync interval is appropriate
const MANIFEST_REFRESH_TAG = 'bg-sync-a';  // opaque — no semantic content in the name
const RESULT_FLUSH_TAG = 'bg-sync-b';

// Use the minimum minInterval that satisfies the functional requirement —
// not the fastest possible; faster intervals = higher site engagement requirement
// and more network activity visible to network-level observers

Service worker sync handlers as a persistence mechanism

A registered sync event will fire even if the page that registered it is no longer open — and with periodic sync, even after the browser has been restarted. This is the intended behavior for reliability, but it means that code in the service worker's sync event handler has a lifetime that extends far beyond the user's active session. If an attacker can influence the service worker's sync handler — through an XSS vulnerability that modifies the handler's logic, through a compromised CDN that serves a modified service worker script, or through a malicious MCP tool that registers sync events with attacker-controlled payloads stored in IndexedDB — that code will continue executing in the background long after the user has left the application.

// Service worker sync handler — executes even after the page is closed:
self.addEventListener('sync', async event => {
  if (event.tag.startsWith('pending-upload:')) {
    const operationId = event.tag.slice('pending-upload:'.length);

    event.waitUntil((async () => {
      // VULNERABLE: reads payload from IndexedDB without integrity verification
      // If an attacker can write to IndexedDB (via XSS), they can poison
      // the payload that this handler will upload after the page closes
      const db = await openDB('mcp-queue');
      const payload = await db.get('pending-uploads', operationId);

      // No signature check — attacker-controlled payload uploaded to server
      await fetch('/api/tool-results', {
        method: 'POST',
        body: JSON.stringify(payload)
      });
    })());
  }
});

// CORRECT: verify payload integrity before processing in sync handler
self.addEventListener('sync', async event => {
  if (event.tag.startsWith('pending-upload:')) {
    const operationId = event.tag.slice('pending-upload:'.length);

    event.waitUntil((async () => {
      const db = await openDB('mcp-queue');
      const entry = await db.get('pending-uploads', operationId);

      if (!entry) return;  // Entry already processed or never existed

      // Verify HMAC or JWT signature on the payload before uploading
      const isValid = await verifyPayloadSignature(entry.payload, entry.signature);
      if (!isValid) {
        console.error('[sync] Payload integrity check failed — discarding:', operationId);
        await db.delete('pending-uploads', operationId);
        return;
      }

      await fetch('/api/tool-results', {
        method: 'POST',
        body: JSON.stringify(entry.payload),
        signal: AbortSignal.timeout(30000)
      });

      await db.delete('pending-uploads', operationId);
    })());
  }
});

Service worker scripts are cached aggressively. A compromised service worker version may continue executing sync handlers for hours or days after the vulnerability has been patched on the server, if the browser has not yet fetched the updated version. MCP servers that use Background Sync should implement a sync handler version check at the start of the handler and fail safe by unregistering all pending syncs if the version is unknown or expired.

Sync tags as cross-origin coordination signals via shared service worker scope

In architectures where multiple origins share a service worker registration scope (e.g., a main app at app.example.com and an MCP client SDK served from the same origin), sync tag registration from one component is visible to all other components sharing the same service worker registration. If the service worker scope encompasses a path used by third-party scripts or embedded iframes at the same origin, those third-party contexts can read sync tags registered by the MCP client, or register sync tags that the MCP client's sync handler will attempt to process.

// VULNERABLE: service worker registered at root scope / — all pages at origin share it
// A third-party analytics script embedded at /analytics.js running on the same origin
// can register sync tags that your service worker's sync handler will process

// navigator.serviceWorker.register('/sw.js', { scope: '/' }) — AVOID for MCP clients

// CORRECT: register the service worker with the narrowest possible scope
navigator.serviceWorker.register('/mcp-client/sw.js', { scope: '/mcp-client/' });
// Now only pages under /mcp-client/ can interact with this service worker registration
// Third-party scripts on other paths cannot register sync events against this SW
Risk Attack vector Defense
Pending queue enumeration Attacker calls getTags() at same origin — reads all pending sync tag names Opaque UUID-based sync tags; operation details in IndexedDB only
Behavioral cadence fingerprinting periodicSync.getTags() reveals internal refresh intervals and tag names Opaque tag names; minimum necessary minInterval; unregister when not needed
Persistence via compromised sync handler Attacker influences IndexedDB payload — sync handler uploads it post-close HMAC/JWT signature on every queued payload; verify before processing
Stale service worker execution Patched SW not yet fetched; old handler continues processing stale sync queue Version check at handler start; unregister all pending syncs if version expired
Broad scope cross-origin coordination Third-party scripts at same origin register sync tags processed by MCP SW Register service worker at narrowest scope covering only the MCP client path

SkillAudit findings for Background Sync API misuse

Critical Sync tags encode session or operation identifiers directly. Tags passed to sync.register() include user IDs, session tokens, tool names, file names, or any information identifying the pending operation. Any same-origin page can enumerate these via getTags() without authentication. Grade impact: −22.
Critical Sync handler processes IndexedDB payloads without integrity verification. The service worker's sync event reads payload data from IndexedDB and uploads it without verifying a signature or MAC. An attacker with XSS access can poison the IndexedDB queue and cause the sync handler to exfiltrate attacker-controlled data after the page closes. Grade impact: −24.
High Service worker registered at root scope when MCP client lives under a subpath. The service worker scope is broader than the MCP client's path, allowing other pages and third-party scripts at the same origin to register sync events that the MCP client's sync handler will attempt to process. Grade impact: −18.
High No service worker version check in sync handler. The sync handler does not verify its own version before processing queued events. A stale, compromised, or misconfigured service worker version may continue processing sync events for hours after a patch has been deployed. Grade impact: −16.
Medium periodicSync registered with unnecessarily short minInterval. The application registers a periodic sync with a minimum interval shorter than the functional requirement justifies. This maximizes background activity, increases battery drain, and creates a larger window for network-level observers to fingerprint sync timing patterns. Grade impact: −10.
Medium Sync handler lacks AbortSignal timeout on network requests. The service worker's sync handler makes fetch() calls without a timeout. A stalled or attacker-controlled endpoint can hold the service worker active indefinitely by never responding, consuming resources and blocking other sync queue processing. Grade impact: −8.

Audit your MCP server for these issues

SkillAudit checks for Background Sync API security misconfigurations automatically — paste a GitHub URL and get a graded report in 60 seconds.

Run a free audit →