Introduction to Fenced Frames
What Are Fenced Frames?
The Fenced Frames API is a privacy-preserving web platform feature that allows embedded content to be displayed on a page without granting the embedding site access to the embedded content's internal data. Think of it as a "privacy boundary" — a special type of frame that renders content in an isolated environment where the outer page cannot inspect, communicate with, or track the frame's contents through DOM access, network inspection, or JavaScript messaging.
Unlike traditional <iframe> elements, fenced frames are purpose-built for scenarios where user data must remain confidential, such as displaying personalized advertisements based on interest groups without leaking that interest group information to the publisher's page.
Why Fenced Frames Matter
Fenced frames are a cornerstone of the Privacy Sandbox initiative, designed to phase out third-party cookies while still enabling key web ecosystem use cases like ad serving, analytics, and content personalization. Here's why they matter:
- Privacy-preserving ad rendering: Advertisers can display relevant ads to users based on their interests without the publisher learning those interests.
- Data isolation: The embedding page cannot exfiltrate data from the fenced frame through DOM scraping, postMessage channels, or timing attacks.
- Shared Storage integration: Works hand-in-hand with the Shared Storage API to run privacy-safe operations like frequency capping and creative rotation.
- Protected Audience API (formerly FLEDGE) support: Enables on-device ad auctions where winning ads are rendered inside a fenced frame, keeping interest group membership hidden.
- No cross-site tracking: The fenced frame's network requests are isolated, preventing the embedding page from correlating user activity across sites.
Core Concepts and Architecture
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →How Fenced Frames Differ from Iframes
The differences between a fenced frame and a regular iframe are profound and intentional. Here is a detailed comparison:
- No DOM access: The embedding page cannot access
contentWindoworcontentDocumenton a<fencedframe>element. Attempts to do so will throw an error or return null. - No postMessage communication: Neither the outer page nor the fenced frame can use
postMessageto communicate. The message channel is completely severed. - Opaque URLs: The
srcattribute may contain an opaque URL — a URL that cannot be inspected by the embedding page. The actual destination remains hidden. - Separate browsing context: Fenced frames run in a completely isolated browsing context with their own process, network partition, and cookie storage.
- No URL observation: The embedding page cannot read the fenced frame's current URL, even as the user navigates within it.
- Restricted permissions: Certain APIs like camera, microphone, and geolocation are not available inside fenced frames by default.
- No window.name access: The
window.nameproperty is not observable across the boundary.
Communication Boundaries Visualized
In a regular iframe, data flows freely in both directions. In a fenced frame, the boundary is rigid:
// ❌ These all FAIL with a fenced frame:
// Cannot access the frame's window object
const frameWindow = document.querySelector('fencedframe').contentWindow;
// Throws: TypeError - Cannot access contentWindow
// Cannot access the frame's document
const frameDoc = document.querySelector('fencedframe').contentDocument;
// Returns null
// Cannot send messages
document.querySelector('fencedframe').contentWindow.postMessage('hello');
// Fails silently or throws
// Cannot read the src attribute if it's opaque
const url = document.querySelector('fencedframe').src;
// Returns an opaque, non-inspectable value
Practical Implementation
Basic Fenced Frame Usage
The most fundamental way to use a fenced frame is with a transparent, non-opaque URL. This serves as the starting point for understanding the element's behavior:
<fencedframe src="https://example.com/embed-content.html">
</fencedframe>
<script>
// Feature detection for fenced frames
if ('FencedFrame' in window) {
console.log('Fenced frames are supported in this browser');
const frame = document.querySelector('fencedframe');
// You can set a non-opaque src programmatically
frame.src = 'https://trusted-partner.com/widget';
// But you still cannot access contentWindow
try {
console.log(frame.contentWindow); // Will throw
} catch (e) {
console.error('Cannot access fenced frame internals:', e.message);
}
} else {
console.log('Fenced frames not supported - consider a fallback');
}
</script>
Using Fenced Frames with Shared Storage
One of the most powerful integrations is with the Shared Storage API. This allows you to run privacy-safe operations in a shared storage worklet and render the resulting URL inside a fenced frame. The key method is sharedStorage.selectURL() which returns an opaque URL:
<fencedframe id="demo-frame"></fencedframe>
<script>
async function runSharedStorageDemo() {
// Check for API support
if (!window.sharedStorage || !window.fencedFrame) {
console.warn('Required APIs not available');
return;
}
try {
// Run a shared storage operation that selects a URL
// The returned URL is opaque - the caller cannot inspect it
const opaqueURL = await window.sharedStorage.selectURL(
'creative-selection', // operation name
[
{ url: 'https://advertiser.com/ad/variant-a.html' },
{ url: 'https://advertiser.com/ad/variant-b.html' },
{ url: 'https://advertiser.com/ad/variant-c.html' }
],
{
// Optional: data passed to the worklet
data: { campaignId: 'summer_sale_2024', budget: 5000 },
// Optional: max age for the selection in milliseconds
maxAge: 3600000, // 1 hour
// Optional: resolve to a config for Protected Audience integration
resolveToConfig: false
}
);
// Set the opaque URL on the fenced frame
// The browser handles the actual navigation internally
const frame = document.getElementById('demo-frame');
frame.src = opaqueURL;
// Note: opaqueURL is not a readable string
// console.log(opaqueURL) would show nothing meaningful
console.log('Fenced frame navigation initiated with opaque URL');
} catch (error) {
console.error('Shared storage operation failed:', error);
}
}
// Register a shared storage worklet first
async function registerWorklet() {
try {
await window.sharedStorage.worklet.addModule(
'https://your-origin.com/shared-storage-worklet.js'
);
console.log('Worklet registered successfully');
runSharedStorageDemo();
} catch (e) {
console.error('Worklet registration failed:', e);
}
}
registerWorklet();
</script>
Here's the corresponding shared storage worklet file (shared-storage-worklet.js) that runs in a secure, isolated environment:
// shared-storage-worklet.js
// This runs inside the shared storage worklet with access to
// shared storage keys but NO access to the outer page DOM
class CreativeSelector {
// The run() method is called for 'selectURL' operations
async run(urls, data) {
// Access shared storage (isolated key-value store)
const viewCount = await this.sharedStorage.get('view-count') || 0;
const lastSeen = await this.sharedStorage.get('last-seen-creative');
console.log('Worklet context - current view count:', viewCount);
console.log('Campaign data from caller:', data);
// Frequency capping logic: limit views per user
if (viewCount >= 3) {
// Return index 0 as fallback / default creative
return 0;
}
// Rotate through creatives based on view count
const selectedIndex = viewCount % urls.length;
// Update the view count in shared storage
await this.sharedStorage.set('view-count', viewCount + 1);
await this.sharedStorage.set('last-seen-creative', urls[selectedIndex].url);
// Add a timestamp for time-based operations
await this.sharedStorage.set('last-access-timestamp', Date.now().toString());
// Return the index of the selected URL
return selectedIndex;
}
}
// Register the operation handler
register('creative-selection', CreativeSelector);
Fenced Frames with Protected Audience API
The Protected Audience API (formerly FLEDGE) runs on-device ad auctions among interest groups stored locally. The winning ad creative is rendered inside a fenced frame using a FencedFrameConfig object. Here's a complete implementation:
<fencedframe id="ad-slot"></fencedframe>
<script>
async function runProtectedAudienceAuction() {
// Verify browser support
if (!navigator.runAdAuction) {
console.warn('Protected Audience API not supported');
showFallbackAd();
return;
}
// Define interest group buyers for the auction
const auctionConfig = {
seller: 'https://publisher.example',
decisionLogicURL: 'https://publisher.example/auction-decision-logic.js',
// Interest group buyers participating in the auction
interestGroupBuyers: [
'https://advertiser-a.example',
'https://advertiser-b.example',
'https://advertiser-c.example'
],
// Auction configuration
auctionSignals: {
contentCategory: 'technology',
pageId: 'article-12345',
userRegion: 'US'
},
// Seller-specific signals
sellerSignals: {
publisherId: 'pub-67890',
floorPrice: 2.50
},
// Timeout for the auction in milliseconds
perBuyerTimeout: 100,
// Number of interest groups to include per buyer
perBuyerGroupLimit: 5,
// Optional: trusted scoring signals
trustedScoringSignalsURL: 'https://publisher.example/trusted-scores.json'
};
try {
// Run the on-device auction
// Returns a FencedFrameConfig if a winning ad exists
const auctionResult = await navigator.runAdAuction(auctionConfig);
if (auctionResult) {
// auctionResult is a FencedFrameConfig object
// It is opaque - the publisher cannot inspect the winning ad URL
const frame = document.getElementById('ad-slot');
// Set the config on the fenced frame
// Use the 'config' attribute (not 'src') for auction results
frame.config = auctionResult;
console.log('Auction winner rendered in fenced frame');
// Optional: observe the fenced frame for events
frame.addEventListener('fencedframe-loaded', () => {
console.log('Fenced frame content has loaded');
// You can track that an ad was displayed, but not which ad
});
} else {
console.log('No winning bid - auction did not produce a result');
showFallbackAd();
}
} catch (error) {
console.error('Ad auction failed:', error);
showFallbackAd();
}
}
function showFallbackAd() {
const frame = document.getElementById('ad-slot');
// Fall back to a contextually targeted ad (non-fenced)
frame.src = 'https://publisher.example/contextual-ad.html';
}
// Run the auction when the page loads
document.addEventListener('DOMContentLoaded', () => {
runProtectedAudienceAuction();
});
</script>
Here's the seller's auction decision logic file (auction-decision-logic.js) that runs in a secure worklet environment:
// auction-decision-logic.js
// Seller's decision logic for Protected Audience API auctions
// This runs in a restricted worklet with no DOM access
function scoreAd(adMetadata, bid, auctionConfig) {
// adMetadata: provided by the buyer's bidding logic
// bid: the bid value from the buyer
// auctionConfig: signals passed from the publisher
const floorPrice = auctionConfig.sellerSignals.floorPrice || 0;
const contentCategory = auctionConfig.auctionSignals.contentCategory;
// Reject bids below the floor price
if (bid < floorPrice) {
return { desirability: -1, rejectReason: 'Below floor price' };
}
// Calculate desirability score
let desirability = bid;
// Boost score for ads relevant to the content category
if (adMetadata.category === contentCategory) {
desirability *= 1.5;
}
// Penalize known low-quality advertisers
if (adMetadata.qualityScore && adMetadata.qualityScore < 0.5) {
desirability *= 0.5;
}
return {
desirability: desirability,
metadata: {
auctionTimestamp: Date.now(),
sellerCalculatedValue: desirability
}
};
}
function reportResult(auctionConfig, browserSignals, sellerSignals) {
// Called after the auction completes
// Can report aggregate data, but NOT user-specific data
return {
reportURL: 'https://publisher.example/auction-reporting',
reportPayload: {
winningBid: browserSignals.winningBid,
auctionDuration: browserSignals.auctionDuration,
totalBids: browserSignals.totalBids
}
};
}
Opaque URLs and Fenced Frame Configs
Understanding opaque URLs and config objects is critical. Here's how they work and how to handle them correctly:
// Opaque URLs and FencedFrameConfig handling
// 1. Opaque URLs from Shared Storage selectURL
async function handleOpaqueURL() {
// selectURL returns an opaque URL
const opaqueURL = await sharedStorage.selectURL('my-operation', urlList);
// The opaque URL is NOT a standard string
console.log(typeof opaqueURL); // 'object' or special opaque type
console.log(opaqueURL.toString()); // Might throw or return empty
// You CAN set it on a fenced frame's src
const frame = document.createElement('fencedframe');
frame.src = opaqueURL; // Browser handles this internally
document.body.appendChild(frame);
// You CANNOT:
// - Extract the actual URL string
// - Use it in a regular iframe
// - Pass it to fetch() or XHR
// - Store it and reuse it arbitrarily (scope may be limited)
}
// 2. FencedFrameConfig from runAdAuction
async function handleAuctionConfig() {
const config = await navigator.runAdAuction(auctionConfig);
if (config instanceof FencedFrameConfig) {
console.log('Received a valid FencedFrameConfig');
// Set on the config attribute (NOT src)
const frame = document.getElementById('ad-slot');
frame.config = config;
// The config object is opaque
// Cannot serialize: JSON.stringify(config) fails
// Cannot inspect: config.toString() gives no useful info
// Once set, attempting to read frame.src returns opaque value
console.log(frame.src); // Opaque, non-inspectable
}
}
// 3. Feature detection for opaque URL support
function supportsOpaqueURLs() {
// Check if fenced frames can handle opaque URLs
const frame = document.createElement('fencedframe');
try {
// Attempt to set a dummy opaque-like object
// True detection involves checking if selectURL is available
return 'sharedStorage' in window &&
typeof window.sharedStorage?.selectURL === 'function';
} catch (e) {
return false;
}
}
Handling Fenced Frame Events
While communication is severely restricted, there are specific lifecycle events you can observe on the fenced frame element itself:
<fencedframe id="monitored-frame"></fencedframe>
<script>
const frame = document.getElementById('monitored-frame');
// 1. Load event - fires when the fenced frame finishes loading
frame.addEventListener('load', (event) => {
console.log('Fenced frame load event fired');
// Note: event.target.src may be opaque
// You know loading completed, but not what loaded
});
// 2. Error event - fires on loading failure
frame.addEventListener('error', (event) => {
console.error('Fenced frame failed to load');
// Implement fallback: show a contextually relevant placeholder
frame.src = 'https://publisher.example/fallback-content.html';
});
// 3. Custom attribute observation with MutationObserver
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'src' || mutation.attributeName === 'config') {
console.log('Fenced frame source/config changed');
// You can detect the change, but not inspect the value
}
});
});
observer.observe(frame, {
attributes: true,
attributeFilter: ['src', 'config']
});
// 4. ResizeObserver for layout changes
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
console.log(`Fenced frame resized to ${width}x${height}`);
// Adjust surrounding layout if needed
}
});
resizeObserver.observe(frame);
</script>
Browser Support and Feature Detection
Fenced frames are rolling out gradually across browsers. Here's how to detect support and implement graceful fallbacks:
// Comprehensive feature detection for Fenced Frames ecosystem
function detectFencedFrameSupport() {
const support = {
// Basic fenced frame element support
fencedFrameElement: false,
// Shared Storage API
sharedStorage: false,
// Protected Audience API (runAdAuction)
protectedAudience: false,
// FencedFrameConfig support
fencedFrameConfig: false,
// Opaque URL handling
opaqueURLs: false
};
// 1. Check for fenced frame element
if ('FencedFrame' in window) {
support.fencedFrameElement = true;
}
// Alternative: check if the element is defined
if (typeof HTMLFencedFrameElement !== 'undefined') {
support.fencedFrameElement = true;
}
// 2. Check for Shared Storage API
if ('sharedStorage' in window) {
const ss = window.sharedStorage;
if (typeof ss?.selectURL === 'function' &&
typeof ss?.worklet?.addModule === 'function') {
support.sharedStorage = true;
}
}
// 3. Check for Protected Audience API
if ('runAdAuction' in navigator) {
support.protectedAudience = true;
}
// 4. Check for FencedFrameConfig
if (typeof FencedFrameConfig !== 'undefined') {
support.fencedFrameConfig = true;
}
// 5. Opaque URL support inferred from shared storage
if (support.sharedStorage && support.fencedFrameElement) {
support.opaqueURLs = true;
}
return support;
}
// Usage with fallback strategy
const capabilities = detectFencedFrameSupport();
if (capabilities.protectedAudience && capabilities.fencedFrameConfig) {
console.log('Full Protected Audience + Fenced Frame support');
// Use the optimal privacy-preserving path
runProtectedAudienceAuction();
} else if (capabilities.sharedStorage && capabilities.opaqueURLs) {
console.log('Shared Storage with opaque URLs available');
// Use shared storage for creative selection
runSharedStorageDemo();
} else if (capabilities.fencedFrameElement) {
console.log('Basic fenced frame support only');
// Use fenced frame with transparent URLs
document.querySelector('fencedframe').src =
'https://trusted-partner.com/basic-embed';
} else {
console.log('No fenced frame support - use iframe fallback');
// Fall back to regular iframe with sandbox attributes
const iframe = document.createElement('iframe');
iframe.src = 'https://trusted-partner.com/fallback-embed';
iframe.sandbox = 'allow-scripts allow-same-origin';
document.getElementById('ad-slot-container').appendChild(iframe);
}
Security and Privacy Considerations
Permissions Inside Fenced Frames
Fenced frames inherit a restricted permission model. The allow attribute controls what features are permitted inside the frame:
<fencedframe src="https://example.com/content.html">
</fencedframe>
<fencedframe
src="https://example.com/content.html"
allow="autoplay; encrypted-media; picture-in-picture"
>
</fencedframe>
<fencedframe
src="https://example.com/content.html"
allow="
autoplay;
encrypted-media;
picture-in-picture;
display-capture;
web-share
"
>
</fencedframe>
<script>
// Permissions that are NEVER available in fenced frames:
// - camera
// - microphone
// - geolocation
// - clipboard-read / clipboard-write
// - fullscreen (requires user gesture and specific context)
// - payment (sensitive financial API)
// Permissions that can be granted via 'allow':
// - autoplay (for video/audio ads)
// - encrypted-media (for DRM content)
// - picture-in-picture (for video ads)
// - display-capture (screen sharing - limited scenarios)
// - web-share (share content from within the frame)
</script>
Network Isolation
Fenced frames enforce network partitioning to prevent cross-site tracking. Here's what developers need to know:
// Network characteristics of fenced frames
/*
* 1. SEPARATE NETWORK PARTITION
* Fenced frames get their own network partition, distinct from:
* - The embedding page's partition
* - Other fenced frames on the same page
* - Regular iframes on the same page
*
* This means cookies, cache, and network state are isolated.
*/
/*
* 2. NO CREDENTIAL SHARING
* The fenced frame cannot access cookies from the outer page's origin.
* Even if both point to the same domain, they are in different partitions.
*/
/*
* 3. FETCH WITHIN FENCED FRAMES
* Code running inside the fenced frame can make fetch() calls,
* but those requests are tagged as coming from the fenced frame partition.
*
* Server-side: Look for the 'Sec-Fenced-Frame' header
*/
// On the server (e.g., Node.js / Express):
app.get('/ad-content', (req, res) => {
if (req.headers['sec-fenced-frame']) {
// Request is from inside a fenced frame
// The fenced-frame header value indicates the embedding context
const isFenced = req.headers['sec-fenced-frame'] === '1';
if (isFenced) {
// Serve privacy-safe content
// Do not set cross-site tracking cookies
res.setHeader('Cookie-Constraints', 'partitioned');
res.send(privacySafeContent);
}
}
// Handle non-fenced requests differently
});
/*
* 4. REPORTING API INTEGRATION
* Fenced frames support the Attribution Reporting API
* for privacy-safe conversion measurement
*/
Best Practices
1. Always Implement Graceful Fallbacks
Not all browsers support fenced frames yet. Design your application to fall back gracefully to iframes with appropriate sandbox attributes or contextual alternatives:
function renderAdSlot(containerId) {
const container = document.getElementById(containerId);
const supportsFenced = 'FencedFrame' in window;
if (supportsFenced && navigator.runAdAuction) {
// Optimal path: fenced frame with Protected Audience
const frame = document.createElement('fencedframe');
frame.id = 'privacy-preserving-ad';
container.appendChild(frame);
runProtectedAudienceAuction(frame);
} else {
// Fallback: sandboxed iframe with contextual targeting
const iframe = document.createElement('iframe');
iframe.sandbox = 'allow-scripts allow-same-origin';
iframe.src = 'https://publisher.example/contextual-fallback-ad';
iframe.style.border = 'none';
container.appendChild(iframe);
}
}
2. Design for Opaque Communication
Accept that you cannot communicate with fenced frame contents. Instead, design your architecture around this constraint:
- Use event listeners on the fenced frame element (load, error) for lifecycle tracking.
- Use Shared Storage worklets for cross-context state management.
- Use the Attribution Reporting API for conversion measurement.
- Use Aggregate Reporting for collecting metrics without individual data.
3. Secure Your Worklet Code
Worklet code for Shared Storage and Protected Audience runs in a highly restricted environment. Follow these guidelines:
// ✅ GOOD: Worklet code is self-contained and secure
class SecureCreativeSelector {
async run(urls, data) {
// Validate inputs
if (!Array.isArray(urls) || urls.length === 0) {
return 0; // Safe default
}
// Use only sharedStorage for state
const count = parseInt(await this.sharedStorage.get('counter')) || 0;
// No access to: DOM, network, fetch, cookies, localStorage
// No access to: window object, parent frame, postMessage
// Perform logic with available APIs only
const next = (count + 1) % urls.length;
await this.sharedStorage.set('counter', next.toString());
return next;
}
}
// ❌ BAD: Attempting to access unavailable APIs
class InsecureSelector {
async run(urls, data) {
// These will FAIL in the worklet context:
// window.localStorage - not available
// document.cookie - not available
// fetch() - not available
// navigator.sendBeacon() - not available
// XMLHttpRequest - not available
// Worklet has ONLY: sharedStorage, console.log, basic arithmetic
return 0;
}
}
4. Handle Timing and Performance
Fenced frame rendering involves additional process isolation. Account for this in your performance budget:
// Monitor fenced frame performance
const frame = document.getElementById('ad-slot');
const startTime = performance.now();
frame.addEventListener('load', () => {
const loadTime = performance.now() - startTime;
console.log(`Fenced frame loaded in ${loadTime.toFixed(2)}ms`);
// Report aggregate timing via private aggregation if available
if (window.privateAggregation) {
window.privateAggregation.sendHistogram({
bucket: Math.floor(loadTime / 100) * 100, // 100ms buckets
value: 1
});
}
});
// Set reasonable timeouts
const LOAD_TIMEOUT = 3000; // 3 seconds
const timeoutId = setTimeout(() => {
console.warn('Fenced frame load timeout - falling back');
frame.src = 'https://publisher.example/fast-fallback.html';
}, LOAD_TIMEOUT);
frame.addEventListener('load', () => {
clearTimeout(timeoutId);
});
5. Test Thoroughly in Multiple Environments
Fenced frame behavior can vary based on browser flags, user settings, and platform. Test across:
- Chrome with Privacy Sandbox features enabled
- Chrome with third-party cookie blocking
- Incognito / private browsing modes
- Different Chrome versions (M115+ for basic support, M120+ for advanced features)
- Mobile vs desktop Chrome
6. Use the Correct Element and Attributes
Remember these critical distinctions:
// ✅ CORRECT: Use element, not
Advanced Integration Patterns
Combining Shared Storage and Protected Audience
For sophisticated ad workflows, you can chain shared storage operations with Protected Audience auctions:
async function advancedAdFlow() {
// Step 1: Use shared storage to check frequency caps
const shouldShowAd = await checkFrequencyCapViaSharedStorage();
if (!shouldShowAd) {
console.log('Frequency cap reached - showing empty slot');
return;
}
// Step 2: Run Protected Audience auction
const auctionConfig = buildAuctionConfig();
const auctionResult = await navigator.runAdAuction(auctionConfig);
if (auctionResult) {
// Step 3: Render winning ad in fenced frame
const frame = document.getElementById('ad-slot');
frame.config = auctionResult;
// Step 4: Update frequency cap via shared storage
// This happens inside the fenced frame's context
// or via a separate sharedStorage call
await updateFrequencyCap();
}
}
async function checkFrequencyCapViaSharedStorage() {
const opaqueURL = await window.sharedStorage.selectURL(
'frequency-check',
[
{ url: 'https://publisher.example/ad-slot-ready.html' },
{ url: 'https://publisher.example/ad-slot-empty.html' }
],
{ data: { maxViews: 3, timeWindow: '24h' } }
);
// The worklet determines which URL to return based on frequency
// We don't know which was chosen, but we can proceed accordingly
//