Skip to content

About

Cross-Origin Storage (COS), a content-addressable browser cache that shares files across origins by hash, with k-anonymity-based availability gating to prevent cross-site tracking

Topics

Resources

Code of conduct

Contributing

Stars

116 stars

Watchers

9 watching

Forks

Repository files navigation

Explainer for the Cross-Origin Storage (COS) API

Cross-Origin Storage (COS) logo, consisting of a folder icon with a crossing person.

This proposal outlines the design of the Cross-Origin Storage (COS) API, a content-addressable cache that allows web applications to store and retrieve files across different origins. Building on the File System Living Standard defined by the WHATWG, the COS API facilitates secure cross-origin file storage and retrieval for large assets, such as AI models, WebAssembly (Wasm) modules, and highly popular JavaScript libraries. Taking inspiration from Cache Digests for HTTP/2, the API identifies files by their content hashes instead of their URL, making it a true content-addressable storage system.

Tip

Try the proposed API with an extension

While this API is not yet natively implemented in browsers, you can experiment with the proposed surface today. Install the Cross-Origin Storage extension to inject the navigator.crossOriginStorage polyfill on all pages and test the complete flow. See the source code of the extension and read the instructions for how to test it.

Tip

Test with your Vite project

If you are building with Vite, you can experiment with COS integration using the experimental vite-plugin-cross-origin-storage plugin. Install it with npm install vite-plugin-cross-origin-storage --save-dev and add it to your vite.config.ts. The plugin automatically rewrites static imports to load vendor chunks and other assets from COS, stores newly fetched assets in COS for future use, and falls back gracefully to standard network requests when COS is unavailable or the asset is not yet cached.

Authors

Participate

Table of Contents

Introduction

The Cross-Origin Storage (COS) API provides a secure, content-addressable cache for web applications to store and retrieve large files across different origins. This allows applications to share common assets, such as AI models, Wasm modules, and popular JavaScript libraries, without redundant downloads. Resources are identified by their cryptographic hashes, which is what makes the cache content-addressable: the same bytes at two different URLs are a single cache entry, and the hash guarantees integrity. The API reuses concepts like FileSystemFileHandle from the File System Living Standard, specifically tailored for cross-origin scenarios. The following example demonstrates the basic flow for retrieving a file:

// The hash of the desired file.
const hash = {
  algorithm: 'SHA-256',
  value: '8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4',
};
try {
  const handle = await navigator.crossOriginStorage.getFileHandle(hash);
  // The file exists in Cross-Origin Storage.
  const fileBlob = await handle.getFile();
  // Do something with the blob.
} catch (err) {
  if (err.name === 'NotAllowedError') {
    // Permissions Policy blocks COS in this context.
    console.log('Cross-Origin Storage is blocked by Permissions Policy.');
  } else if (err.name === 'NotFoundError') {
    console.log('The file was not found in Cross-Origin Storage.');
  }
  return;
}

Goals

COS aims to:

  • Provide a cross-origin storage mechanism for web applications to store and retrieve large files such as AI models, Wasm modules, and highly popular JavaScript libraries.
  • Guarantee data integrity and consistency for file identification (see Appendix B).
  • Make the web more sustainable and ethical by reducing redundant downloads of large resources the user agent may already have stored locally.

Non-goals

COS does not aim to:

  • Replace existing storage solutions such as the Origin Private File System, the Cache API, IndexedDB, or Web Storage.
  • Replace content delivery networks (CDNs).
  • Allow cross-origin file access without the possibility for the user agent to intervene.
  • Modify or supersede the same-origin policy.
  • Manage downloads. COS stores complete files or shards that the developer reassembles after retrieval, so resuming a failed download stays the job of the Background Fetch API or fetch() requests with Range headers.

User research

Feedback from developers working with large AI models, Wasm modules, and highly popular JavaScript libraries has highlighted the need for an efficient way to store and retrieve such large files across web applications on different origins. These developers are looking for a standardized solution that allows files to be stored once and accessed by multiple applications, without needing to download and store the files redundantly. COS ensures this is possible while maintaining privacy and security.

User needs example: Hugging Face

Joshua Lochner (aka. Xenova) from Hugging Face had the following to say in his talk at the 2024 Chrome Web AI Summit:

"One can imagine a browser-based web store for models similar to the Chrome Web Store for extensions. From the user's perspective, they could search for web-compatible models on the Hugging Face hub, install it with a single click, and then access it across multiple domains. Currently, Transformers.js is limited in this regard, since models are cached on a per site or per extension basis."

User needs example: Web Machine Learning Working Group

Participants of the Web Machine Learning Working Group at the W3C in their meeting on September 21, 2023, discussed Storage APIs for caching large models. A proposal named Hybrid AI Explorations listed the following open issues:

"If the model runs on the client, large models need to be downloaded, possibly multiple times in different contexts. This incurs a startup latency."

"Models are large and can consume significant storage on the client, which needs to be managed."

This led to the creation of a dedicated Hybrid AI explainer, which in its introduction states:

"For example, ML models are large. This creates network cost, transfer time, and storage problems. As mentioned, client capabilities can vary. This creates adaptation, partitioning, and versioning problems. We would like to discuss potential solutions to these problems, such as shared caches, progressive model updates, and capability/requirements negotiation."

Use cases

Use case 1: Large AI models

Developers working with large AI models can store these models once and access them across multiple web applications. By using the COS API, models can be stored and retrieved based on their hashes, minimizing repeated downloads and storage, while ensuring file integrity. For examples of web-runnable models, see the WebLLM Chat app.

Use case 2: Large Wasm modules

Web applications that utilize large Wasm modules can store these modules using COS and access them across different origins. This enables efficient sharing of files between applications, reducing redundant downloading and improving performance. A notable example is Google's Flutter framework, which uses several Wasm files that are requested millions of times daily across thousands of hosts:

Request (https://gstatic.com/flutter-canvaskit/) Size Hosts Requests
36335019a8eab588c3c2ea783c618d90505be233/chromium/canvaskit.wasm 5.1 MB 1,938 596,900
a18df97ca57a249df5d8d68cd0820600223ce262/chromium/canvaskit.wasm 5.1 MB 1,586 579,380
36335019a8eab588c3c2ea783c618d90505be233/canvaskit.wasm 6.4 MB 1,142 597,240
a18df97ca57a249df5d8d68cd0820600223ce262/canvaskit.wasm 6.4 MB 1,014 288,800

(Source: Google-internal data from the Flutter team: "Flutter engine assets by unique hosts - one day - Dec 10, 2024".)

Use case 3: Highly popular JavaScript libraries and frameworks

Traditionally, bundlers have combined vendor code and user code, leading to low cache hit rates even before the regular HTTP cache was isolated. By bundling vendor code separately and in its entirety (for example, the complete, untreeshaken React library), developers can ensure a higher cache hit rate. Storing such files once with the COS API allows multiple web apps to share the same highly popular libraries.

Use case 4: Game engines

Web games built with game engines that have browser support such as Godot or Unity can store the core game engine code in COS and only load game-specific assets such as textures and game logic from the network. Web gaming portals such as WebGamer that host plenty of casual games with a short path to gameplay on different cross-origin iframes can benefit greatly from this.

Use case 5: Large web fonts

Web fonts (especially large icon fonts, emoji fonts, and fonts with extensive Unicode coverage) are downloaded across an enormous number of pages daily. Popular fonts served by services like Google Fonts (for example, Noto Color Emoji or Material Symbols) are requested by thousands of different sites. If these fonts were stored once in COS, any site using the same font could load it from the user's device and skip the CDN download, benefiting both performance and sustainability.

Potential solution

The imperative API

The COS API will be available through the navigator.crossOriginStorage interface. Files will be stored and retrieved based on their hashes, ensuring that each file is uniquely identified.

Who may read an entry depends on how it was shared. An entry is always available to the origins that stored it and to their same-site origins, which is the default. A write can widen that to a list of named origins, or to every origin ('*'). A listed origin also brings its same-site origins along, so naming https://a.example covers https://www.a.example too. Same-site and list sharing work for any file. Sharing with every origin carries one more condition: an origin outside the other grants only learns that the file is present if its hash is on the Public Hash List (PHL). The PHL is a vendor-neutral list of resources so widespread on the web that having one of them cached reveals nothing about which sites the user visited. Even for a hash on the PHL, the user agent may occasionally answer such an origin as if the file were absent, a technique called GREASE'ing. Availability gating describes the rules in full, and the Public Hash List explainer covers how hashes get onto the list.

COS entry

Each resource stored in COS is conceptually represented as an entry with the following fields. An entry exists only once its bytes have been written and verified, so there is no half-written state; see In-progress writes.

  • hash: the content identifier, consisting of an algorithm (a string naming a hash algorithm recognized by the Web Crypto API other than "SHA-1", e.g. "SHA-256") and a value (a 64-character lowercase hex string in the case of "SHA-256"). Entries are keyed by hash: two files with identical bytes and the same hash algorithm are the same entry, regardless of how many origins stored them or from how many URLs they were fetched.
  • bytes: the raw file contents. The user agent verifies at write time that hashing bytes with hash.algorithm produces hash.value; a mismatch throws a DataError and stores nothing.
  • origins: the declared sharing scope, stored as two independent, additive grants: an explicit origins list and a globally disclosable flag, set by a '*' write. A write requests '*', a list of origins (each covering its same-site origins as well), or nothing (same-site only), and the request is merged into these grants (see Resource visibility upgrades). A list is capped in length and bounded by the Cross-Origin-Storage-Allow-Origin header.
  • storing origins: the origins that have successfully written this entry, each recorded with the top-level site it wrote under. It is persisted across page loads and only ever grows.

Grants are keyed to the top-level site, the same way storage partitioning keys a frame's other storage. A storing origin's access, and the same-site default that follows from it, hold only under the top-level site of the write. An origins list admits the listed origins under the top-level site of the write, and a list written first-party (by a top-level page, or by a frame same-site with it) also admits them wherever they run first-party. The global grant is not keyed.

Availability gating describes who may read an entry through each grant.

Storing files

  1. Hash the contents of the file using SHA-256 (or an equivalent secure algorithm, see Appendix B). The hash algorithm used is communicated as a string naming a hash algorithm recognized by the Web Crypto API.
  2. Request a FileSystemFileHandle object for the file, specifying the file's hash.
  3. Write the file's data to the FileSystemFileHandle object and store it in Cross-Origin Storage. Data can be written with one or more write() calls, or streamed in with sourceStream.pipeTo(writableStream). By default, pipeTo() closes writableStream automatically once sourceStream is exhausted, unless called with preventClose: true (see Streaming a file into COS while using it for the recommended pattern on large resources). Whenever the stream closes, whether via an explicit writableStream.close() call or implicitly through pipeTo(), the user agent must verify that the hash of the complete written bytes matches the declared hash, using the algorithm specified in hash.algorithm. If the hashes do not match, the user agent must reject the closing operation's promise with a DataError DOMException and must not store the data in COS.

Note

A write registers nothing until its bytes have been verified, so a hash-mismatched write leaves no trace and a write in progress is invisible to every origin, including the writer's own later lookups; see Concurrent writes.

Note

If hash.value is not a valid lowercase hexadecimal string of length 64, or hash.algorithm is not a hash algorithm name recognized by the Web Crypto API, or is "SHA-1", the user agent must throw a TypeError.

Note

If the Permissions Policy for the current context does not allow Cross-Origin Storage, the user agent must throw a NotAllowedError DOMException.

Note

If origins is a list longer than an implementation-defined maximum length, the user agent must throw a TypeError; see Cross-site probing for why.

Note

Each entry in an origins list must be an https: origin or a loopback http: origin such as http://localhost, which are the only origins that can call the API. For any other entry, such as http://a.example, wss://a.example, or chrome-extension://…, the user agent must throw a TypeError.

Note

If storing the file would cause the requesting origin to exceed its implementation-defined storage limit, the user agent must reject the closing operation's promise with a QuotaExceededError DOMException and should log a warning to the console. Each origin can only store a limited amount of data in COS, which prevents any one site from flooding the cache in an attempt to evict other sites' resources; see Cache flooding.

Example: Storing a single file
/**
 * Example usage to store a single file.
 */

// The hash of the desired file.
const hash = {
  algorithm: 'SHA-256',
  value: '8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4',
};

// First, check if the file is already in COS.
try {
  const handle = await navigator.crossOriginStorage.getFileHandle(hash);
  // The file exists in COS.
  const fileBlob = await handle.getFile();
  // Do something with the blob.
  console.log('Retrieved', fileBlob);
  return;
} catch (err) {
  // If the file wasn't in COS, load it from the network and store it in COS.
  if (err.name === 'NotFoundError') {
    // Load the file from the network.
    const fileBlob = await loadFileFromNetwork();
    try {
      const handle = await navigator.crossOriginStorage.getFileHandle(
        hash,
        {
          create: true,
          // Optional: Only allow these origins to read the file.
          origins: ['https://example.com', 'https://example.org'],
        },
      );
      const writableStream = await handle.createWritable();
      await writableStream.write(fileBlob);
      await writableStream.close();
    } catch (err) {
      // The `write()` failed.
    }
    return;
  }
  // 'NotAllowedError': Permissions Policy blocks COS in this context.
  console.log('Cross-Origin Storage is blocked by Permissions Policy.');
}
Example: Streaming a file into COS while using it

The example above waits for the whole file to arrive before writing it, which is fine for small resources but throws away the download/consume overlap that streaming APIs such as WebAssembly.instantiateStreaming() provide. For large resources, the recommended pattern on a cache miss is to tee() the network response body: one branch is consumed immediately, the other is piped into COS in the background. Because pipeTo() closes the writable stream when the source is exhausted, and the user agent verifies the hash on close, no explicit write() or close() call is needed. On a cache hit, File.stream() gives the same streaming shape from the stored bytes.

/**
 * Example usage to stream a Wasm module into COS while compiling it.
 */

const hash = {
  algorithm: 'SHA-256',
  value: '8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4',
};
const wasmHeaders = { headers: { 'Content-Type': 'application/wasm' } };

try {
  // Cache hit: stream from the stored file. The bytes were hash-verified when
  // they were written, so a fixed MIME type is safe.
  const handle = await navigator.crossOriginStorage.getFileHandle(hash);
  const file = await handle.getFile();
  const { instance } = await WebAssembly.instantiateStreaming(
    new Response(file.stream(), wasmHeaders),
    imports,
  );
  return instance;
} catch (err) {
  if (err.name !== 'NotFoundError') {
    throw err;
  }
}

// Cache miss: split the body so compilation and storage proceed in parallel.
const response = await fetch('/model.wasm');
if (!response.ok) {
  throw new Error(`HTTP ${response.status}`);
}
const [compileStream, storeStream] = response.body.tee();

// Fire-and-forget store; never block on the write.
(async () => {
  try {
    const handle = await navigator.crossOriginStorage.getFileHandle(hash, {
      create: true,
      origins: '*',
    });
    const writableStream = await handle.createWritable();
    // Closes `writableStream` on completion; rejects with a `DataError` if the
    // bytes don't match `hash`.
    await storeStream.pipeTo(writableStream);
  } catch (err) {
    // Release the unconsumed branch so the body isn't buffered indefinitely.
    storeStream.cancel().catch(() => {});
  }
})();

const { instance } = await WebAssembly.instantiateStreaming(
  new Response(compileStream, wasmHeaders),
  imports,
);
return instance;

The same shape works for any consumer that accepts a ReadableStream, for example a model loader that parses safetensors headers as bytes arrive, or new Response(stream).blob() if the consumer ultimately needs a Blob. Note that a tee()'d stream buffers whatever the slower branch has not yet read, so a store branch that never consumes must be canceled, as shown above.

Note

The fetch integration collapses the entire example into a single fetch() call and leaves the stream splitting to the user agent.

Example: Choosing who can read a file

The origins option decides who can read a file once it is stored:

  • Omitted: only same-site origins can read it. This fits resources shared across subdomains of one site, such as a company's proprietary AI model.
  • A list of origins: only the listed origins, the origins same-site with each of them, and the same-site default can read it. This option is recommended for proprietary resources or resources for which global COS cache hits are not anticipated. For example, if a company has two related sites, write.example and calculate.example, that both use the same AI model for proofreading, they can restrict the model to just these two origins.
  • '*': any origin can read it, subject to availability gating. This option is appropriate for widely used resources that many sites are likely to share, such as popular AI models, Wasm modules, or JavaScript libraries. It is an explicit opt-in, so developers cannot make a resource globally available by accident.
// Same-site only.
await navigator.crossOriginStorage.getFileHandle(hash, { create: true });

// Only `calculate.example` and `write.example`. Any other origin gets a
// `NotFoundError`, even if the file is stored in COS.
await navigator.crossOriginStorage.getFileHandle(hash, {
  create: true,
  origins: ['https://calculate.example', 'https://write.example'],
});

// Any origin, if the hash is on the Public Hash List.
await navigator.crossOriginStorage.getFileHandle(hash, {
  create: true,
  origins: '*',
});

// Then write the file through the returned handle.

Note

For this restricted sharing to take effect, a Cross-Origin-Storage-Allow-Origin response header must authorize the listed origins. Whoever supplies the bytes sends the header, and with the imperative API the page's own script supplies them, so write.example sends Cross-Origin-Storage-Allow-Origin: https://calculate.example, https://write.example on the response for the document making the write. The header is the ceiling; the origins array can only narrow it. Any listed origin the header does not authorize is dropped, which stops content injected into the page from redirecting the disclosure to an origin the operator never approved. See The Cross-Origin-Storage-Allow-Origin header.

Resource visibility upgrades

The visibility of a resource in COS can be upgraded but never downgraded:

  • Adding access: any site that supplies the full bytes can widen an entry's scope with another create: true request. A '*' write sets the global grant, and a list write merges its origins into the existing list; origins that already had access keep it. Requiring the full bytes keeps a write from revealing whether the entry already existed.
  • No removal: a write never removes access. If origin A stores a file restricted to ['https://a.example'] and origin B later writes the same hash with '*', the entry becomes globally disclosable and keeps its list, so https://a.example still reads it without the hash being on the PHL. A narrower request only adds its origins to the list, and the user agent should log a console warning that the write cannot restrict the resource.
  • Origins list capacity: the maximum list length also caps the merged list. Once separate writes have filled it, later writes still succeed, the excess origins are dropped, and the user agent should log a console warning.

Retrieving files

To retrieve a file, call getFileHandle() with its hash and no create option, as shown in the Introduction. To work with several files, call getFileHandle() once per file and combine the calls with Promise.all(); the FAQ entry on why the API is singular explains why there is no batched form.

Note

A NotFoundError DOMException does not necessarily mean the file is absent from COS. User agents may suppress availability of a file for privacy reasons (see Availability gating). Callers should handle NotFoundError by falling back to a network fetch, regardless of the cause.

Example: Choosing among interchangeable resources

Sometimes the hashes a caller holds are alternatives, and the caller wants whichever one the user already has. This is the everyday situation for AI models, which are published as families of interchangeable variants that differ in size and quality but expose the same interface. An app may be built around whisper-tiny because that is the smallest download it can justify, but it would rather transcribe with whisper-large-v3 if the user already downloaded that one on some other site. Downloading the small model while a better one already sits on the device is the worst of both worlds: the user pays for bytes and gets worse transcriptions.

Expressing this means asking COS a question before committing to any download: which of these do you already have?

/**
 * Example usage to pick the best locally available variant of a model.
 */

// The only variant the app ever downloads, so the only one with a URL.
const download = {
  name: 'whisper-tiny',
  url: 'https://cdn.example/models/whisper-tiny.bin',
  hash: {
    algorithm: 'SHA-256',
    value: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
  },
};

// Better variants, most capable first. These are only probed: the app uses
// one if the user already has it, and never downloads it.
const upgrades = [
  {
    name: 'whisper-large-v3',
    hash: {
      algorithm: 'SHA-256',
      value:
        '8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4',
    },
  },
  {
    name: 'whisper-medium',
    hash: {
      algorithm: 'SHA-256',
      value:
        'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad',
    },
  },
];

// Probe the upgrades first, then the download itself, which the user may also
// already have from another site.
for (const candidate of [...upgrades, download]) {
  try {
    const handle = await navigator.crossOriginStorage.getFileHandle(
      candidate.hash
    );
    // Found one, so nothing needs to be downloaded at all.
    console.log('Using locally available model', candidate.name);
    return { name: candidate.name, file: await handle.getFile() };
  } catch (err) {
    if (err.name !== 'NotFoundError') {
      // 'NotAllowedError': Permissions Policy blocks COS in this context.
      throw err;
    }
    // Not available, so try the next candidate.
  }
}

// None of them is available, so download the one variant the app ships with.
const fileBlob = await fetch(download.url).then((response) => response.blob());
console.log('Obtained model from network', download.name);

Only whisper-tiny carries a URL, because it is the only variant the app will ever download. Every probe is a read with no URL attached: the app has no download URL to offer for whisper-large-v3, since it never intended to fetch that variant, and the whole point of asking is to avoid a network request. A fetch integration cannot express this, which is one of the reasons it complements the imperative API (see Replacing the imperative API with a fetch() integration).

Note

Each getFileHandle() call counts as a probe against the user agent's cross-site probing safeguards, so candidate lists are expected to be short, in the order of the handful of variants a model family actually ships.

Transferring a handle

A FileSystemFileHandle is serializable, so a handle for a COS entry can be passed to another context with postMessage(), a MessagePort, or a BroadcastChannel, the same way any other file handle can. This is a second way to obtain a handle, so the same disclosure rules apply to it:

  • Same-origin only. Deserializing a COS handle in a context whose origin differs from the one that obtained it throws a DataCloneError. A readable handle cleared availability gating for the origin that asked; passing it to another origin would hand over the bytes without origins, the Public Hash List, or GREASE'ing ever being evaluated for that origin. Transferring between a page and its own worker, or between same-origin documents, works normally.
  • Readability travels with the handle. A handle from a create: true request that has not been written through is still not readable after being transferred: getFile() keeps rejecting until that handle's own write completes. Conversely, a handle from a successful read stays readable without being re-checked, so transferring a handle can't be used to re-roll GREASE'ing or otherwise re-probe availability.
// Same-origin: fine. The worker gets a handle it can read from.
const handle = await navigator.crossOriginStorage.getFileHandle(hash);
worker.postMessage(handle);

// Cross-origin: throws `DataCloneError` on the receiving side.
otherOriginFrame.postMessage(handle, 'https://other.example');

Additional integration surfaces

The imperative JavaScript API in the previous section covers the general case, but a large share of real-world resource loading already happens through constructs that carry a URL and, increasingly, an integrity hash. Routing those through getFileHandle() means hand-writing a cache check, a fallback fetch, and a store, which is boilerplate the user agent can just as well perform itself. COS is therefore designed to be reachable from four host integrations:

Surface Opt-in Reaches
HTML crossoriginstorage attribute <link> and <script> subresources
JavaScript imports crossOriginStorage import attribute static and dynamic module imports
CSS cross-origin-storage() URL modifier CSS-referenced assets such as web fonts
Fetch crossOriginStorage request option imperative fetches of a known URL

In all four, the integrity hash identifies the file in COS, and the COS option expresses the same three scopes as the origins option of getFileHandle(): same-site only, a list of origins (each covering its same-site origins as well), or * for global availability. With getFileHandle() and the Fetch option, same-site is the default when the option is omitted, and an empty list requests it as well. An empty string is not a scope there: it fails to parse as an origin and throws a TypeError. The HTML attribute, the import attribute, and the CSS modifier can only carry text, so for them an empty value also means same-site. Each is defined in its own host specification.

As with the imperative API, the list form is bounded by a response header so that injected markup cannot widen the sharing scope. Only the list form needs this header: a resource shared with every origin (*) or left at the same-site default (value omitted or empty) needs no Cross-Origin-Storage-Allow-Origin header. Whoever supplies the bytes sends the header, and for these integrations that is the server of the fetched resource, such as the origin serving a font or a library. That origin is the one entitled to decide that those particular bytes may be shared, and the referencing page can only narrow that; the embedding document's own header plays no part. The effective scope is the intersection of the declared value and what the resource's Cross-Origin-Storage-Allow-Origin header permits. See The Cross-Origin-Storage-Allow-Origin header.

What the four have in common is that the caller holds both a URL and a hash, and wants the bytes. The imperative API remains the surface for everything that does not fit that shape: writes whose bytes did not come from a single fetch(), reads that have no URL to offer at all, and lookups across a set of interchangeable candidates (see Choosing among interchangeable resources). See Replacing the imperative API with a fetch() integration for why the last row of the table does not subsume getFileHandle().

HTML integration

<link> and <script> elements that already carry integrity can opt in to COS with a new crossoriginstorage attribute, proposed to the WHATWG in whatwg/html#12770.

Example: Opting stylesheets and scripts into COS

A valueless crossoriginstorage attribute means same-site only, * makes the resource globally available, and a space-separated list of origins restricts it to those origins and their same-site origins:

<!-- Same-site only. -->
<link
  rel="stylesheet"
  href="https://static.acme-inc.example/same-site-css-framework.css"
  integrity="sha256-abc123..."
  crossoriginstorage
/>

<!-- Globally available. -->
<script
  src="https://cdn.example/popular-js-framework.js"
  integrity="sha256-def456..."
  crossoriginstorage="*"
></script>

<!--
  Restricted to specific origins. `https://acme-cdn.example` serves this
  script, so its response must carry
  `Cross-Origin-Storage-Allow-Origin: https://acme-inc.example, https://acme-cdn.example`.
-->
<script
  src="https://acme-cdn.example/acme-inc-corporate.js"
  integrity="sha256-def456..."
  crossoriginstorage="https://acme-inc.example https://acme-cdn.example"
></script>

Omitting crossoriginstorage entirely while keeping integrity preserves today's behavior: the resource is fetched and verified, and COS plays no part.

Note

crossoriginstorage is unrelated to the existing crossorigin attribute despite the similar name. The crossorigin attribute controls the CORS request mode for the element's fetch, which is an orthogonal concern.

JavaScript import attribute integration

Import attributes provide a way to reach COS from module imports and dynamic import(), without going through navigator.crossOriginStorage directly, proposed to the WHATWG in whatwg/html#12771.

Note

The with { … } syntax is defined by TC39, but crossOriginStorage is a host-defined attribute key. Like integrity, it requires no TC39 involvement and will be defined in the HTML Standard.

Example: Opting modules into COS

An empty string means same-site only, "*" makes the module globally available, and a space-separated list of origins restricts it to those origins and their same-site origins:

// Same-site only.
import sameSite from "https://static.acme-inc.example/same-site-resource.js" with {
  integrity: "sha256-abc123...",
  crossOriginStorage: "",
};

// Globally available.
import popular from "https://cdn.example/popular-resource.js" with {
  integrity: "sha256-abc123...",
  crossOriginStorage: "*",
};

// Restricted to specific origins. `https://acme-cdn.example` serves this
// module, so its response must carry
// `Cross-Origin-Storage-Allow-Origin: https://acme-inc.example, https://acme-cdn.example`.
import corporate from "https://acme-cdn.example/acme-inc-corporate.js" with {
  integrity: "sha256-def456...",
  crossOriginStorage: "https://acme-inc.example https://acme-cdn.example",
};

The same attributes work with dynamic import():

const module = await import("https://cdn.example/popular-resource.js", {
  with: {
    integrity: "sha256-abc123...",
    crossOriginStorage: "*",
  },
});

CSS integration

In addition to the imperative JavaScript API, COS can be accessed from CSS via a new <request-url-modifier> called cross-origin-storage(), proposed to the CSS Working Group in w3c/csswg-drafts#14056. This is especially valuable for resources referenced in CSS, such as large web fonts, where the imperative JavaScript API is hard to apply.

The modifier is used alongside the existing integrity() modifier.

cross-origin-storage() = cross-origin-storage( [ '*' | <string># ]? )
Example: Opting fonts into COS

No arguments means same-site only, * makes the font globally available, and a list of origins restricts it to those origins and their same-site origins; all other origins still fetch the font from the network URL:

/* Same-site only. */
@font-face {
  font-family: "Same-Site Corporate Font";
  src: url(
    "https://static.acme-inc.example/same-site-corporate.woff2"
    integrity("sha256-abc123...")
    cross-origin-storage()
  );
}

/* Globally available. */
@font-face {
  font-family: "Popular Emoji Font";
  src: url(
    "https://cdn.example/popular-emoji.woff2"
    integrity("sha256-xyz789...")
    cross-origin-storage(*)
  );
}

/*
  Restricted to specific origins. `https://acme-cdn.example` serves this font,
  so its response must carry
  `Cross-Origin-Storage-Allow-Origin: https://acme-inc.example, https://acme-cdn.example, https://acme-marketing.example`.
*/
@font-face {
  font-family: "ACME Inc Corporate Font";
  src: url(
    "https://acme-cdn.example/acme-inc-corporate.woff2"
    integrity("sha256-abc123...")
    cross-origin-storage("https://acme-inc.example", "https://acme-cdn.example", "https://acme-marketing.example")
  );
}

Note

cross-origin-storage() is unrelated to the CSS cross-origin() modifier despite the similar name. The cross-origin() modifier controls the CORS request mode, which is an orthogonal concern.

Fetch integration

The three integrations above cover resources referenced from markup, from module graphs, and from stylesheets. The remaining case is the imperative one: a script that already knows the URL and the hash of a resource and fetches it itself. That is how most Wasm modules, asset bundles, and other large binaries are loaded today, and it is currently the case that costs the most code to move onto COS.

A crossOriginStorage option on RequestInit, used alongside the existing integrity option, closes that gap. This is proposed to the WHATWG in whatwg/fetch#1954, where it would be defined as:

dictionary CrossOriginStorageRequestOptions {
  (DOMString or sequence<DOMString>) origins;
  DOMString contentType;
};

partial dictionary RequestInit {
  (DOMString or sequence<DOMString> or CrossOriginStorageRequestOptions) crossOriginStorage;
};

"*" and the array form are shorthands for { origins }, and "" for {}, which leaves origins out. The dictionary form additionally lets the caller declare a contentType, a proposed answer to the first of the open design questions below.

Example: Fetching through COS

An empty string opts the resource into COS for same-site access only, * makes it globally available, and an array of origins restricts it to those origins and their same-site origins:

// Same-site only.
const sameSite = await fetch('https://static.acme-inc.example/same-site-resource.wasm', {
  integrity: 'sha256-abc123...',
  crossOriginStorage: '',
});

// Globally available.
const global = await fetch('https://cdn.example/popular-resource.wasm', {
  integrity: 'sha256-abc123...',
  crossOriginStorage: '*',
});

// Restricted to specific origins. `https://acme-cdn.example` serves this
// resource, so its response must carry
// `Cross-Origin-Storage-Allow-Origin: https://acme-inc.example, https://acme-cdn.example`.
const restricted = await fetch('https://acme-cdn.example/acme-inc-corporate.wasm', {
  integrity: 'sha256-def456...',
  crossOriginStorage: [
    'https://acme-inc.example',
    'https://acme-cdn.example',
  ],
});

Omitting crossOriginStorage while keeping integrity preserves today's behavior: the response is fetched and verified, and COS plays no part. This is why same-site scope is spelled as an empty string: fetch() has no create: true to carry the opt-in separately, so the member's presence is what opts the request into COS and its value is what scopes the result. The imperative API, which has create: true, expresses the same scope by omitting origins.

crossOriginStorage only works with GET requests, which is the default method. A COS hit returns the stored bytes without sending the request to the server, so combining it with POST, PUT, or any other method would throw a TypeError up front, since those requests usually exist to send a body or change something on the server.

Note

The list form is an array here, whereas the HTML attribute and the import attribute use a space-separated string and the CSS modifier a comma-separated list of <string>s. This is deliberate because a RequestInit member is an ordinary JavaScript value, so a sequence<DOMString> is the idiomatic spelling, and it matches the imperative origins option exactly, down to the IDL type. The three other surfaces have no such choice to make: HTML content attribute values are text, import attribute values are currently restricted to strings by TC39, and CSS has no array type, so each takes the closest list syntax its host already provides. All four resolve to the same origins value space.

Example: The streaming example, without the plumbing

The streaming example above is the recommended way to write a cache-miss path by hand today, and it is around 30 lines of tee(), pipeTo(), and cancellation for what is conceptually a single fetch. The common case is easy to get wrong, and getting it wrong silently costs the download and compile overlap that WebAssembly.instantiateStreaming() exists to provide. With the fetch integration, the whole example collapses to:

const { instance } = await WebAssembly.instantiateStreaming(
  fetch('https://cdn.example/module.wasm', {
    integrity: 'sha256-abc123...',
    crossOriginStorage: { origins: '*', contentType: 'application/wasm' },
  }),
  imports,
);

The user agent performs the COS lookup, serves the bytes from storage on a hit, fetches and stores them on a miss, and does the stream splitting internally.

Note

The contentType member is a proposed answer to Response fidelity on a cache hit. Without it, this example works on a cache miss, where the network response carries Content-Type: application/wasm, and fails on a hit, where the stored bytes carry no type and WebAssembly.instantiateStreaming() rejects them.

Note

Server runtimes such as Node.js, Deno, and Bun implement fetch() but have no cross-origin boundary and no user to protect, so COS does not exist there. They ignore crossOriginStorage the way they ignore other browser-specific request options, and isomorphic code keeps working unchanged.

Open design questions

Two questions are specific to this integration and need answers in the Fetch Standard discussion:

  • Response fidelity on a cache hit. A COS entry stores bytes only (see Storing the original URL as part of a COS entry), so a Response served from a hit has no Content-Type. The three other integrations take the type from the element, the module type, or the CSS property; a plain fetch() has no destination to take it from. WebAssembly.instantiateStreaming() requires application/wasm, so without a declared type the collapsed example above works on a cold cache and fails on a warm one. The proposed answer is a caller-declared contentType in the option's dictionary form, applied on hits and misses alike, so both return the same Content-Type.
  • Header stripping. A Response served from COS cannot carry the headers of a fetch that never happened. This discloses no more than getFileHandle() does: hits are timing-observable anyway, and the read is gated by origins, the Public Hash List, and GREASE'ing. A hit reports status 200 with status text OK and streams the stored bytes as its body. A declared contentType settles Content-Type, and the hit carries no other headers. Which type a hit reports is open.

Processing flow common to all four integrations

  1. The user agent looks the integrity hash up in COS. If the requesting origin may read the entry (see Availability gating), the resource is served from COS and no network request is made.
  2. Otherwise, the resource is fetched as usual. If it matches the integrity hash, the user agent stores it in COS with the declared scope; if not, it fails per existing integrity behavior and nothing is stored.

A lookup that doesn't succeed is indistinguishable from a cache miss, exactly as getFileHandle()'s NotFoundError is.

Note

The hash format differs between these four integrations and the imperative form. The integrity attribute, the integrity import attribute, the integrity() CSS modifier, and the integrity request option follow the Subresource Integrity convention and express hashes as base64-encoded strings (e.g., sha256-abc123…). The imperative getFileHandle() API uses lowercase hexadecimal strings (e.g., 8f434346…), which matches the format AI model hubs such as Hugging Face use for model checksums. The user agent normalizes both representations internally; they identify the same bytes.

Detailed design discussion

Hashing

The current hashing algorithm is SHA-256, implemented by the Web Crypto API. If hashing best practices should change, COS will reflect the implementers' recommendation in the Web Crypto API.

The hashing algorithm used is encoded in the hash object's algorithm field as a plain string naming a hash algorithm recognized by the Web Crypto API, e.g. "SHA-256". This flexible design allows changing the hashing algorithm in the future. "SHA-1" is excluded: it is not collision resistant, and a collision would let one site's bytes answer reads made for another site's bytes under the same key. The hash string must be a valid lowercase hexadecimal string of length 64 (for SHA-256).

Note that algorithm is typed as a plain DOMString, even though its value space is exactly the set of names a HashAlgorithmIdentifier accepts. HashAlgorithmIdentifier is (object or DOMString), and the object branch exists so parameterized algorithms like HMAC can carry extra fields (e.g. {name: "HMAC", hash: "SHA-256"}). Hash algorithms take no such parameters, and algorithm is stored, compared, and round-tripped as part of a content-addressable key, so admitting arbitrary objects here would add no capability while complicating equality and serialization.

const hash = {
  algorithm: 'SHA-256',
  value: '8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4',
};

Concurrent writes

Two tabs can find the same hash absent and start writing it at the same time. The user agent stores the file once; this proposal does not coordinate the two downloads.

  • A write in progress is invisible. A create: true request puts nothing in the COS registry. An entry appears only once a writer has supplied the complete bytes and the user agent has verified them against the hash, so a lookup for a hash whose only write is still in flight returns NotFoundError, exactly as it would for a hash no origin has ever written. Callers cannot detect that someone else is mid-download, and must not try to.
  • A handle from a create: true request rejects getFile() with NotAllowedError until that handle's own write has completed. This answer is the same whether or not the hash is stored, and a handle cannot be deserialized cross-origin, so it reaches only the origin that made the request.
  • When a write fails, for example because its bytes don't match the hash, there is nothing to clean up: the failed writer never registered anything, and an entry another origin has already written is untouched. Later lookups get whatever the registry holds, independent of the failure.
  • When two writes succeed, whichever completes second finds the entry the first created and adds its own origin and origins grants to it. Both sets of bytes hash to the same value, so storing them once is enough.

Related origins can still deduplicate downloads themselves, with Web Locks, a BroadcastChannel, or a shared worker. Unrelated origins have no way to coordinate, by design.

What a COS handle can and cannot do

getFileHandle() returns an ordinary FileSystemFileHandle. A COS entry has no name, no containing directory, and no identity beyond its hash, so the handle's operations behave as follows:

  • name is the entry's hash. The File System Standard defines name as the last component of the handle's locator path, and a COS locator path is the hash.
  • isSameEntry() reports whether the two handles' hashes match, since the registry holds at most one entry per hash. It rejects when the other handle belongs to a different file system or was not obtained by the calling origin, because the caller may not inspect that handle.
  • move() and remove() reject with NotAllowedError, the error removeEntry() uses when readwrite access is not granted. An entry is shared by every origin that stored it, so deletion is left to eviction and the user's storage controls, and a rename has nothing to act on. Neither method is standardized yet (WICG/file-system-access#214).
  • Permission queries never return prompt, since handles are pre-authorized. For write access, they return denied on any handle not obtained from a create request, because only such a handle can call createWritable(). Requesting permission returns the same result.
  • createSyncAccessHandle() rejects with InvalidStateError, as the File System Standard specifies for any handle outside a bucket file system. This also prevents a writable file descriptor from changing an entry's bytes after verification, bytes every origin the entry is disclosed to would then read.
  • createWritable({ keepExistingData: true }) starts empty. Seeding the stream with an existing entry's bytes would let a caller close it without writing anything and become a storing origin for bytes it never supplied, gaining read access without origins, the Public Hash List, or GREASE'ing being consulted. A writable closed without any write therefore holds zero bytes and fails verification with DataError, like any other mismatch.

Workers and origin inheritance

A CrossOriginStorageManager uses the origin of its context. For a worker, that is the origin of the worker's environment settings object, independent of its script URL:

  • Workers created from a blob: URL have the origin of the context that created them and share that page's COS view: each reads what the other stored, and the worker's writes count as the page's own. Revoking the blob: URL does not change this.
  • Workers created from a data: URL have an opaque origin, which has no stable identity to key storing origins or same-site checks on. This proposal does not yet define what getFileHandle() does for an opaque calling origin; the origin rule in "validate a COS request" covers only the origins a caller names in origins. Implementations should at minimum reject such calls promptly, so the returned promise never stays pending.
  • Service workers do not expose navigator.crossOriginStorage for now; see below.

The Permissions Policy check applies to the calling global. A dedicated or shared worker may use COS only if every document in its owner set may, resolved transitively through any intervening workers, so Permissions-Policy: cross-origin-storage=() also covers calls moved into new Worker(URL.createObjectURL(blob)). A service worker has no owner set: its registration persists, and the browser starts it in response to events, often with no client open. Supporting service workers needs a policy captured from their own script response, most plausibly a Permissions-Policy header, which is work for the Permissions Policy and Service Workers specifications. Also see explainers-by-googlers/workers-permissions-policy#4.

Eviction

Under critical storage pressure, user agents could offer a dialog that invites the user to manually free up storage. The user agent could also delete files automatically based on, for example, a least recently used approach.

User agents are further expected to provide settings UI through which users can inspect which files are stored in COS and which origins have most or least recently accessed each file. Users may then choose to delete files from COS through this UI. This UI could also let users add manually downloaded files, such as large AI models already on disk, to COS directly.

When the user clears site data, all usage information associated with the origin should be removed from files in COS. If a file in COS, after the removal of usage information, is deemed unused, the user agent may delete it from COS.

Web sustainability

Minimizing redundant downloads and storage is inherently beneficial for sustainability. The Ethical Web Principles state that the Web "is an environmentally sustainable platform" and suggest "lowering carbon emissions by minimizing data storage and processing requirements", which is what COS does for large files the user may already have on their device.

Considered alternatives

Adding a description for each file apart from the hash

To facilitate manual COS management, one approach would be to allow developers to store a human-readable description alongside the resource. Apps could reference to the same file identified by a unique hash using different descriptions. For example, an English site could refer to the g-2b-it-gpu-int4.bin AI model as "Gemma AI model from Google", whereas another Spanish site could refer to it as "modelo de IA grande de Google". Instead, we envision user agents to enrich COS management UI based on the hashes. For example, a user agent could know that a file identified by a given hash is a well-known AI model and optionally surface this information to the user in the user agent settings UI.

Storing the original URL as part of a COS entry

Recording the URL each file was fetched from would make a multi-gigabyte blob legible in the browser's storage UI and help a developer debug a getFileHandle() miss. COS entries do not store it, for three reasons:

  • The URL belongs to one writer's fetch. An entry is shared by every origin that stores its bytes, and ten origins may fetch the same file from ten different URLs. No single URL represents the entry: the first storer has no special standing, and a set of URLs would grow without bound and accept additions from any origin that writes the bytes.
  • It can't be verified. The hash verifies the bytes; a URL is only the writer's claim, and an unverified label inside an otherwise verified structure would read as provenance.
  • It leaks more than an origin does. COS's disclosure limits (origins, availability gating, and the Public Hash List) are calibrated for origin-level information. A full URL can carry paths, query parameters, tokens, and user identifiers (https://cdn.example/models/user-1234/weights.bin).

Other uses of a stored URL are covered elsewhere. After eviction, the calling origin already knows its own URL and can fetch it again, and a re-fetch by the user agent would send a request to a third party's URL with ambient authority. Permission UI already has the requesting origin at prompt time. Popularity corroboration happens during Public Hash List admission, offline and once for everyone.

For debugging and storage inspection, a user agent may keep an implementation-private provenance record, such as the URL each storing origin fetched the bytes from, and when, as browser state kept outside the entry (see Provenance metadata in the spec):

  • No COS API exposes it to script, and an origin cannot observe a record it did not produce.
  • Only trusted surfaces show it: the browser's settings and storage inspection UI, developer tools, and extension APIs behind an explicit, user-granted permission, on the same footing as other APIs that expose browsing history (see Browser extension integration points for COS).
  • It is presented as the writing origin's unverified claim. Only the hash guarantees the content, and two origins may record different URLs for the same entry.
  • It is discarded with the entry, and per origin when that origin leaves the entry's storing origins, including when the user clears that origin's site data.

Content cannot detect whether a user agent keeps such a record, so keeping one is an implementation choice.

Storing files without hashing

Storing files by their names would risk name collisions, especially in a cross-origin environment. The use of hashes guarantees unique identification of each file, ensuring that the contents are consistently recognized and retrieved. Storing files based on their URLs would work if apps reference the same URLs, for example, on the same CDN, but wouldn't work if apps reference the same file stored at different locations.

Requiring a minimum file size

One approach would be to require a minimum file size for a resource to be eligible for COS. No minimum file size is proposed. It would be trivial to inflate a file's size to meet any such threshold, for example by appending padding bytes or comments.

Manually accessing files from a local disk

Different origins can manually open the same file on disk, either using the File System Access API's showOpenFilePicker() method or using the classic <input type="file"> approach. This requires the file to be stored once, and access to the file can then be shared as explained in Cache AI models in the browser. While this works, it's manual and error-prone, as it requires the user to know what file to choose from their hard drive in the file picker.

Replacing the imperative API with a fetch() integration

COS is reachable from fetch() (see Fetch integration), and getFileHandle() remains alongside it: a fetch couples naming a resource to downloading it, and the imperative API keeps the two separate. Three things depend on that separation:

  • Bytes from sources other than a single fetch(). Download management is a non-goal, and stored bytes may come from a Background Fetch, from Range requests for a sharded resource the site reassembles itself, from a file the user picked from disk, or from another storage API. A shard has no URL that serves it, so a fetch integration cannot store it.
  • Reads with no URL. A lookup may only ask whether COS holds a hash, with nothing to download if it doesn't, for example when an app probes for a better model variant it never intended to fetch (see Choosing among interchangeable resources). A fetch-shaped probe would have to name a URL the app does not want to request.
  • Handles. A FileSystemFileHandle can be transferred to another context, read several times, and written through with the File System Standard machinery developers already use for OPFS. A Response is a single, one-shot body, and a store-only write has no request to make.

The imperative API is the general surface, and the four host integrations are shortcuts for the common case where a URL and a hash are both known up front and the bytes are wanted immediately.

Integrating cross-origin storage in the Cache API

The Cache API is fundamentally modeled around the concepts of Request or URL strings, and Response, for example, Cache.match() or Cache.put(). In contrast, what makes COS unique is that it uses file hashes as the keys to files to avoid duplicates.

Solving the problem only for AI models

AI models are admittedly the biggest motivation for working on COS, so one alternative would be to solve the problem exclusively for AI models. A question that arises in the context is how it would be enforced that files actually be AI models? Given this question, this approach does not seem like a good fit, and the non-AI use cases are well worth addressing, too.

Additionally, common AI inference solutions like Transformers.js rely on WebAssembly in the underlying ONNX Runtime, which is true independent of the backend, WebGPU or Wasm. The same applies to MediaPipe, which requires Wasm files as so-called WasmFileset objects for its various MediaPipe Tasks APIs.

Security and privacy considerations

See the complete questionnaire for details.

Security considerations

Resource integrity check through hashes

Access is scoped to individual files, each identified by its hash: a site can request only a file whose hash it already knows, and cannot list what COS contains. The user agent verifies every file against its hash (for example, SHA-256) when it is written, so a site reading from COS gets exactly the bytes it would have downloaded itself. User agents can additionally check hashes against malware databases such as VirusTotal, or consult in-browser protections such as Safe Browsing, before storing a file.

User controls

Users can inspect, evict, and clear COS files through the user agent's settings UI; see Eviction.

Cache flooding

A per-origin storage limit keeps any one site from flooding the cache to evict other sites' resources; see the QuotaExceededError note under Storing files.

The Cross-Origin-Storage-Allow-Origin header

The origins list form discloses a writer's bytes to origins it does not control. Script (the origins option) and markup (the crossoriginstorage attribute and its siblings) declare that list, so an attacker who can inject either on the writing origin could write data the compromised page can read into a list-scoped entry naming an attacker-controlled origin, and read it back later from that origin. The write produces no network egress at the moment of compromise, so egress monitoring, a connect-src allowlist, or a CSP report endpoint sees a clean page load. CSP does not contain exfiltration in general either, since a top-level navigation to an attacker URL also leaves the page.

A list therefore needs authorization from a response header that injected content cannot forge. Whoever supplies the bytes sends the header:

  • Imperative API: the bytes may be generated in script, so the header comes from the writing document's response.
  • Host integrations: the bytes come from a fetched resource, so the header comes from that resource's response. This also keeps a site from declaring another site's asset shareable with origins of its choosing.

The header lists origins separated by commas, each of which must be an https: origin or a loopback http: origin. A header with any other value, including *, authorizes nothing, exactly as an absent one does. The declared list is intersected with the origins the header names. Unnamed origins are dropped, and if no cross-origin recipient remains, the write still succeeds with the same-site default. The check applies per writer, so a later writer can add only origins its own response authorizes and cannot change an earlier writer's scope.

Only the list form needs the header. A '*'-scoped resource reaches a non-storing origin only if its hash is on the Public Hash List, which a per-user secret never is, and the same-site default names no cross-origin recipient.

The header bounds disclosure to the origins the operator authorized. If a site legitimately shares data with https://partner.example and is then compromised, injected script can still reach that partner. This matches the guarantee connect-src gives: the attack surface shrinks from any origin on the web to the operator's declared partners.

Privacy considerations

In browsers that still support third-party cookies, COS lets a tracker link a user's visits across sites no better than a third-party cookie already can. By applying the mitigations listed below, COS can also be implemented in browsers without support for third-party cookies.

Since every COS lookup and write passes through one explicit API, the user agent knows exactly where tracking would have to happen. That single mediation point is what makes it possible to count, gate, and rate-limit cross-site disclosure precisely, something browsers cannot do for signals they never see, such as those hiding in timing side channels or scattered platform quirks.

Cross-site tracking through writes

COS lets a tracker write, which turns it into a potential cross-site store. Embedded as a third-party iframe, tracker.example can call getFileHandle(hash, { create: true }) on site A to store a chosen subset of small files, each file's presence encoding one bit, and read that subset back from the same iframe on site B. COS rules this out by keying grants to the top-level site (see COS entry). On site B, the frame is a storing origin only for what it wrote under site B, so reading site A's files takes the global grant, and minted marker files are never on the Public Hash List. The frame's lookups count against site B's budget like any other origin's.

A tracker script loaded directly into the page runs as the site's own origin, so everything it stores belongs to site A, and a script on site B runs as B and is not a storing origin. To read those files back from B, it has to make them cross-origin readable. A list naming B needs site A to send a Cross-Origin-Storage-Allow-Origin header authorizing B, which only the site operator can do. With origins: '*', the only files other origins can see are those whose hashes are on the Public Hash List, so the tracker has to encode its bits as a strategically chosen subset of well-known public files. It then reads through the global grant, where GREASE'ing may drop some bits at random and files the user already cached on other sites add false signals, so the tracker needs redundancy to recover a stable identifier. Eviction and the per-origin storage limit bound both variants, and the identifier lasts as long as the tracker rewrites it, or until the cache evicts it.

Potential mitigations

Mitigations need to carefully balance between ensuring the user's privacy and maintaining the usefulness of the feature.

  • Every lookup that could reveal what another site stored, whether it finds the file or not, counts against a small budget of cross-site lookups, on the order of 8 to 16 per time window, both budget and time window defined by the user agent. Lookups for files the requesting site stored itself, under this top-level site, stay free. The budget belongs to the top-level site the user is visiting (its scheme and registrable domain), and every frame on the page draws from it, so a tracker cannot multiply it by adding origins or by moving the page through subdomains of one site.
  • A file written with origins: '*' becomes available through the global grant only after a user gesture on the page, while the page itself can use the file right away. This is to prevent writes during silent reloads of the page. The gate applies to the "*" grant alone: same-site origins and origins on an explicit list, which the site authorizes with its Cross-Origin-Storage-Allow-Origin header, read the file as soon as it is written.
  • The count persists across page reloads for the whole top-level site (and across tabs), so reloading does not reset it.
  • Each call to getFileHandle() can further be limited for sites known to be malicious, for example, from Safe Browsing.

Since each lookup reveals at most one bit, a budget of 8 limits a tracker to 8 bits per window, well short of the roughly 32 bits needed to identify a device. A patient tracker can still combine partial results over time, so cross-site tracking becomes slow and paced by the user's own engagement, but not impossible.

Cross-site probing

If a file is only used on certain kinds of websites, an attacker can discover that the user visited those sites by checking for the file's presence. For example, if someone has a game engine stored in COS, they probably play games on the web, which an attacker might exploit, for example, for targeted advertising. The attacker site would need to probe hashes of resources it's interested in. The origins field mitigates this risk by allowing origins to restrict resource access to a specific set of trusted origins, ensuring the resource is not globally "probeable". Sites are expected to use this field for proprietary resources or when global COS cache hits are not expected.

This mitigation only holds if a list stays meaningfully smaller than the web. A caller could otherwise enumerate a very large number of origins (for example, a public top-sites ranking) and approximate global disclosure without the explicit '*' opt-in. origins lists therefore have an implementation-defined maximum length that fits a handful of related origins under common control, and the Cross-Origin-Storage-Allow-Origin header bounds which origins a list may name.

A lookup performed by one of the host integrations counts as a probe on the same terms. Such a lookup returns no error to the page, but a site learns its outcome anyway by observing whether its own server receives the fallback request, which is the same single bit a NotFoundError carries. This discloses nothing the imperative API would not, and the same origins scoping, availability gating, and GREASE'ing apply. It does mean a probe limit must count all four surfaces: the fetch integration in particular is as scriptable in a loop as getFileHandle() is, so counting only imperative calls would leave the limit trivially avoidable.

In-progress writes

A write that has been requested and not completed must look, to every origin, exactly like a hash that was never written. COS gets this structurally: getFileHandle() with create: true neither reads nor writes the COS registry, and an entry is added only after a writer supplies the complete bytes and the user agent verifies them.

Registering a placeholder instead, and answering concurrent reads of it with a distinguishable error, would hand any origin one noiseless bit about any hash, ahead of origins, the PHL, and GREASE'ing, and without writing any bytes for the storage limit to bound.

Availability gating

Whether a getFileHandle() call returns a handle depends on the grants an entry carries. Grants are set at write time, add up, and are never removed (see Resource visibility upgrades):

  • Storing origins can always read the entry under the top-level site they wrote it under, mirroring the partitioned Cache API, where an origin can always read what it stored.
  • Same-site origins of a storing origin can read it under that same top-level site. This is the default scope.
  • Origins on the explicit origins list, and origins same-site with any of them, can read it, whether or not the hash is on the PHL. Any list admits them under the writer's top-level site, and a list written first-party also admits them wherever they run first-party.
  • Any other origin can read it only through the global grant (origins: '*'), and only if the hash is on the Public Hash List (PHL), a shared, vendor-neutral allowlist all browser vendors are expected to respect. GREASE'ing may still withhold it.

An origin that qualifies under none of these, or relies on the global grant for a hash not on the PHL, receives a NotFoundError DOMException that is identical in content and timing to the file being absent. A requester that qualifies through the first three grants never consults the PHL, even if the entry is also globally disclosable. The PHL gates only the global grant because the other grants already reflect a bounded disclosure decision by the storing origin; requiring public curation for them would block ordinary restricted sharing of proprietary resources (see Choosing who can read a file).

The PHL covers well-known resources, such as popular open-source libraries, widely used Wasm modules, web fonts served by major font CDNs, and AI model weights published by recognized model hubs. These are unconditionally eligible for cross-origin availability disclosure because independent, corroborated evidence of their ubiquity (for example, appearing byte-identical across a large number of independently crawled origins) makes cache presence uninformative about any individual user, a form of k-anonymity where k is that minimum corroborating-origin count. This ubiquity check happens once, offline, as part of how a hash is admitted to the PHL, so the user agent never repeats it at query time.

The full design of the PHL (its data format, admission criteria, sourcing, and cross-vendor governance) is specified in the Public Hash List explainer. In short, it proposes: governance by the WHATWG, modeled directly on the Public Suffix List's cross-vendor, rolling-release precedent; a compact, algorithm-sectioned flat-text format of bare hex digests, with provenance kept in human-readable comments; and a separate, optional section for hashes hand-curated from a recognized AI model hub, to unlock the AI use case that objective popularity signals alone cannot cover. An early, non-normative code prototype of the list itself lives in this repository for now, at public-hash-list/implementation/. The Governance section of the PHL explainer describes the target end state, a dedicated, cross-vendor repository.

Developers must NOT treat a NotFoundError as proof that a file is absent from COS: the requesting origin may be out of scope, or the user agent may be withholding a '*'-scoped file for privacy reasons. The fallback is always a network fetch.

GREASE'ing

As an additional privacy mitigation, user agents may employ GREASE'ing (Generate Random Extensions And Sustain Extensibility): occasionally returning a NotFoundError DOMException even when a file is present in COS. This introduces noise that makes it harder for sites to distinguish a true absence from a privacy-motivated false negative. A similar technique is applied in UA Client Hints.

GREASE'ing applies only to origins that reach an entry through origins: '*', the same case the PHL gates. Storing origins, origins on an explicit origins list, and the same-site origins of either are never GREASEd: same-site origins are one trust unit, and a listed origin was named on purpose by the storing site and authorized by its Cross-Origin-Storage-Allow-Origin header, so withholding the file from them would only cost a re-download.

However, user agents must exercise size-proportionate judgment when applying GREASE'ing. For small files, where a fallback to a network fetch is inexpensive, occasional false negatives are a reasonable privacy trade-off. For very large files, such as gigabyte-scale AI model weights, a false negative would force the caller to perform a full re-download, imposing a significant and observable bandwidth and latency cost on the user. User agents must NOT GREASE responses for files whose size makes a spurious re-download clearly disproportionate to the privacy benefit.

API response reference

The following tables summarize the response a user agent must return for every combination of inputs.

Read path

The rows are keyed by how the requesting origin qualifies (see Availability gating). A "—" means the column does not apply to that row.

Requester qualifies via On PHL? GREASEd? Response
Storing origin — — Success
Same-site of a storing origin — — Success
On the explicit origins list, or same-site with a listed origin — — Success
Global grant only (origins: '*') Yes No Success
Global grant only (origins: '*') Yes Yes NotFoundError
Global grant only (origins: '*') No — NotFoundError
No qualifying grant (out of scope) — — NotFoundError
Not in COS — — NotFoundError

A hash whose only write is still in flight falls under the last row: it has no entry in COS yet, so it is NotFoundError like any other absent hash. There is no response that distinguishes it; see In-progress writes.

getFile() is gated per handle rather than by this table. A handle from a create: true request rejects with NotAllowedError until that handle's own write completes, whether or not another origin has already written the entry. Otherwise a create request would be a read: any origin could ask for a handle and immediately call getFile(), learning an entry's contents without satisfying origins, the PHL, or GREASE'ing, all of which are enforced on the read path only. Because that answer is the same either way, and a handle cannot be deserialized cross-origin, it discloses nothing.

Write path
Condition Written with Response
hash.value or hash.algorithm is malformed Any TypeError
origins is a list longer than the implementation-defined maximum length Any TypeError
An origins entry is not an https: origin or a loopback http: origin List TypeError
Permissions Policy blocks COS Any NotAllowedError
Valid hash, declared hash matches computed hash Any Success
Valid hash, declared hash matches computed hash, but exceeds the requesting origin's storage limit Any QuotaExceededError
Valid hash, declared hash does not match computed hash Any DataError
Merging origins into an existing list-scoped entry would exceed the implementation-defined maximum length List Success (excess origins silently dropped)
A listed origin is not permitted by the writer's Cross-Origin-Storage-Allow-Origin header List Success (unauthorized origins dropped; falls back to same-site if none remain)
Transfer path
Condition Response
Deserializing a handle in a context same-origin with the one that obtained it Success, preserving whether it was readable
Deserializing a handle in any other origin DataCloneError

Fingerprinting detection

User agents are also expected to use (on-device) machine learning to identify possible fingerprinting attempts. For example, if a site crafts unique hashes for each user (which hints at fingerprinting), user agents can detect this and block the COS probing attempt. Some user agents have successfully applied this technique to silence notification spam.

Stakeholder feedback / opposition

  • Web Developers: Expressed support for enabling sharing of large files without redundant downloads and storage, particularly large AI models, large Wasm modules, and highly popular JavaScript libraries.

References

Acknowledgments

Many thanks for valuable feedback from:

  • Tab Atkins-Bittner, Google Chrome
  • Yash Raj Bharti, Google Cloud
  • Joshua Lochner, Hugging Face
  • Patrick Meenan, Google Chrome

Many thanks for valuable inspiration or ideas from:

  • Kenji Baheux, Google Chrome
  • Kevin Moore, Google Chrome

Appendices

Appendix A: Full IDL

Copied from the formal spec on every commit.

[Exposed=(Window,DedicatedWorker,SharedWorker), SecureContext]
interface CrossOriginStorageManager {
  Promise<FileSystemFileHandle> getFileHandle(
      CrossOriginStorageGetFileHandleHash hash,
      optional CrossOriginStorageGetFileHandleOptions options = {});
};

dictionary CrossOriginStorageGetFileHandleHash {
  required DOMString value;
  required DOMString algorithm;
};

dictionary CrossOriginStorageGetFileHandleOptions {
  boolean create = false;
  (DOMString or sequence<DOMString>) origins;
};

interface mixin NavigatorCrossOriginStorage {
  [Exposed=(Window,DedicatedWorker,SharedWorker), SameObject, SecureContext]
  readonly attribute CrossOriginStorageManager crossOriginStorage;
};
Navigator includes NavigatorCrossOriginStorage;
WorkerNavigator includes NavigatorCrossOriginStorage;

Appendix B: Blob hash with the Web Crypto API

async function getBlobHash(blob) {
  const hashAlgorithmIdentifier = 'SHA-256';

  // Get the contents of the blob as binary data contained in an ArrayBuffer.
  const arrayBuffer = await blob.arrayBuffer();

  // Hash the arrayBuffer using SHA-256.
  const hashBuffer = await crypto.subtle.digest(
    hashAlgorithmIdentifier,
    arrayBuffer,
  );

  // Convert the ArrayBuffer to a hex string.
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  const hashHex = hashArray
    .map((byte) => byte.toString(16).padStart(2, '0'))
    .join('');

  return {
    algorithm: hashAlgorithmIdentifier,
    value: hashHex,
  };
}

// Example usage:
const fileBlob = await fetch('https://example.com/ai-model.bin').then(
  (response) => response.blob(),
);
getBlobHash(fileBlob).then((hash) => {
  console.log('Hash:', hash);
});

Appendix C: Frequently asked questions (FAQ)

Question: Would the first site that added a file be seen as the authority?

Answer: No, each site has the same powers. If the user stops using the first site that has put a given file into COS, but continues using another site that depends on the same file, the file would stay around. Only if no site depends on the file anymore, the user agent may consider the file for manual or automatic removal from COS if it's under storage pressure or based on regular storage house keeping.

Question: Why does the API use getFileHandle() (singular) rather than requestFileHandles() (plural)?

Answer: Early drafts of the API exposed requestFileHandles(hashes, options), which accepted an array of hashes and returned an array of FileSystemFileHandle objects. A survey of every known real-world implementation (Hugging Face Transformers.js, wllama, Flutter, Apache TVM, MLC WebLLM, Emscripten, and others) found that every single call site passed a single-element array and immediately destructured the result to a single handle. No implementation ever passed more than one hash in a single call.

The plural form was therefore pure ergonomic friction: callers had to wrap a value in an array only to unwrap it again (const [handle] = await ...requestFileHandles([hash])). The singular form getFileHandle(hash, options), modeled directly on the File System Standard's FileSystemDirectoryHandle.getFileHandle(), makes the common case clean and readable. Where FileSystemDirectoryHandle.getFileHandle() takes a name, crossOriginStorage.getFileHandle() takes a hash object that identifies the file, and the options follow the same model: without create: true, the user agent returns a handle for an existing file, and with it, a handle that can be written to. On a create request, origins restricts who can later read the file or makes it globally available. For the rare case where multiple files are needed concurrently, the idiomatic JavaScript pattern Promise.all(hashes.map(hash => navigator.crossOriginStorage.getFileHandle(hash))) gives better per-file error granularity than a batched call would anyway.

Question: Was getFileHandle() always called getFileHandle()?

Answer: No. A previous version of this proposal called the method requestFileHandle(). The name getFileHandle() was changed to requestFileHandle() in issue #4, at a time when the method showed a permission prompt and the request prefix matched other prompting web APIs. Handles are now pre-authorized and the method never prompts, so it uses the same get prefix as FileSystemDirectoryHandle.getFileHandle() again.

About

Cross-Origin Storage (COS), a content-addressable browser cache that shares files across origins by hash, with k-anonymity-based availability gating to prevent cross-site tracking

Topics

Resources

Code of conduct

Contributing

Stars

116 stars

Watchers

9 watching

Forks

Releases

Packages

Used by

Contributors

Languages