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.
- Thomas Steiner, Google Chrome
- Christian Liebel, Thinktecture AG
- François Beaufort, Google Chrome
- Spec (source)
- Public Hash List explainer
- Issues
- PRs
- Support this proposal: expression of support
- Introduction
- Goals
- Non-goals
- User research
- Use cases
- Potential solution
- Detailed design discussion
- Considered alternatives
- Adding a description for each file apart from the hash
- Storing the original URL as part of a COS entry
- Storing files without hashing
- Requiring a minimum file size
- Manually accessing files from a local disk
- Replacing the imperative API with a
fetch()integration - Integrating cross-origin storage in the Cache API
- Solving the problem only for AI models
- Security and privacy considerations
- Stakeholder feedback / opposition
- References
- Acknowledgments
- Appendices
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.requestFileHandle(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;
}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.
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 withRangeheaders.
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.
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."
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."
In their standards position on the Writing Assistance APIs, Mozilla engineer Brian Grinstead wrote:
"We acknowledge a downside with this approach related to lack of shared client storage for model weights — it would be a better experience if the browser only had to download large weights one time. We don’t know of a privacy-preserving way to do this, short of high level APIs like these which abstract away the details of inference."
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.
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".)
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.
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.
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.
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 ('*'). 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 widespread resources 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, so a "not found" result never proves the file is missing. Availability gating describes the rules in full, and the Public Hash List explainer covers how hashes get onto the list.
Each resource stored in COS is conceptually represented as an entry with the following fields:
hash: the content identifier, consisting of analgorithm(a string naming a hash algorithm recognized by the Web Crypto API, e.g."SHA-256") and avalue(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 hashingbyteswithhash.algorithmproduceshash.value; a mismatch throws aDataError.origins: the declared sharing scope. Internally this is two independent, additive grants: an explicit origins list (a possibly-empty list of origins granted PHL-independent access) and a globally disclosable boolean (whether the entry was written with'*', granting access to any origin whose request clears the PHL). A write requests one of'*', a list of origin strings, or nothing (same-site only), and that request is merged into the grants; every entry additionally always grants access to its storing origins and their same-site origins. Both grants only ever grow, which is what makes visibility "upgradeable but never downgradeable": a later'*'write adds the global grant on top of an existing list, so an origin already on the list keeps its access. See Resource visibility upgrades. A list of origin strings has an implementation-defined maximum length, so it can't be used as an undeclared substitute for'*'(see Storing files and Cross-site probing), and the list form is additionally bounded by aCross-Origin-Storage-Allow-Originresponse header, so injected script cannot disclose an origin's data to an origin the operator never authorized (see TheCross-Origin-Storage-Allow-Originheader).storing origins: the set of origins that have successfully written this entry. An origin instoring originsmay always retrieve the entry viarequestFileHandle(), regardless of theoriginsfield value or whether the hash is on the PHL.
storing origins is persisted across page loads and only ever grows, adding each new origin that successfully writes the entry. If origin A writes a file restricted to ['https://a.example'] and origin B later writes the same hash with origins: '*', both A and B are in storing origins, the entry becomes globally disclosable, and it keeps its explicit list: https://a.example still reads it without the hash needing to be on the PHL, exactly as before B's write, while other origins now reach it only if the hash is on the PHL. Because the global grant is stored separately from the list, B cannot revoke the access A granted. Each writer must supply the full file bytes regardless of whether the entry already exists, which prevents any origin from using a write operation to detect prior presence.
- 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.
- Request a
FileSystemFileHandleobject for the file, specifying the file's hash. - Write the file's data to the
FileSystemFileHandleobject and store it in Cross-Origin Storage. Data can be written with one or morewrite()calls, or streamed in withsourceStream.pipeTo(writableStream). By default,pipeTo()closeswritableStreamautomatically oncesourceStreamis exhausted, unless called withpreventClose: true(see Streaming a file into COS while using it for the recommended pattern on large resources). Whenever the stream closes, whether via an explicitwritableStream.close()call or implicitly throughpipeTo(), the user agent must verify that the hash of the complete written bytes matches the declared hash, using the algorithm specified inhash.algorithm. If the hashes do not match, the user agent must reject the closing operation's promise with aDataErrorDOMExceptionand must not store the data in COS.
Note
A hash-mismatched write does not leave a stuck placeholder behind. Once no other write for that same hash is still in progress, the user agent removes the entry entirely, so a subsequent requestFileHandle() call for that hash behaves exactly as if it had never been requested and rejects with NotFoundError. This never applies to a hash some origin has already successfully written before: that entry is never removed by a later, unrelated write's failure, no matter how many times it's attempted. 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, 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. This limit exists so that a list of origins can't be used to approximate origins: '*' without going through its explicit opt-in; see Cross-site probing.
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 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.requestFileHandle(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.requestFileHandle(
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.');
}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.requestFileHandle(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.requestFileHandle(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.
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 (plus 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.exampleandcalculate.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.requestFileHandle(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.requestFileHandle(hash, {
create: true,
origins: ['https://calculate.example', 'https://write.example'],
});
// Any origin, if the hash is on the Public Hash List.
await navigator.crossOriginStorage.requestFileHandle(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.
The visibility of a resource in COS can be upgraded but never downgraded:
- Adding access: If a resource was initially stored with an
originslist, any site (including the original storer or a completely different site) can later callrequestFileHandle()for the same hash withcreate: trueand a more permissive value. The requested grant is added: a'*'write sets the globally disclosable flag while leaving the existing list intact, and a list write merges its origins into the existing list. Origins that already had access keep it. The new site must still write the full file using the returnedFileSystemFileHandleobject, to prevent sites from using this behavior to detect whether a file was previously stored. - No removal: A write never removes access. Requesting a narrower scope than an entry already has does not restrict it: if a globally disclosable resource is written again with an
originslist, the global grant stays in force (visibility can't be downgraded), and the listed origins are simply added to the explicit list, which gives them access even when the hash is not on the PHL. The user agent should log a console warning to inform the developer that the write did not, and cannot, restrict the resource. Because the list and global grants are stored independently, no writer can revoke another writer's grant. This closes the case where a later'*'write would otherwise have knocked a listed origin off a list-scoped entry and, via availability gating, silently revoked its access. - Origins list capacity: The same implementation-defined maximum length that bounds a single write's
originslist (see Storing files) also bounds the merged list when a newly requestedoriginslist is added to an entry that already has one. This can only be reached by the cumulative effect of separate writes by different, possibly unrelated, sites over time, since any single write's own list is already capped. When it is reached, the write still succeeds, since the bytes were already verified and stored. The origins beyond capacity are silently dropped from the merge, and the user agent should log a console warning. Because aNotFoundErrorcan't be distinguished from other gating outcomes (see Availability gating), a site cannot reliably confirm after the fact whether its requested origin actually made it into the merge. - Original storer access: An origin that stores a resource in COS can always read it back via
requestFileHandle(), regardless of theoriginsvalue set at write time or whether the hash is on the PHL. This mirrors the Cache API's model where an origin always has access to what it stored.
To retrieve a file, call requestFileHandle() with its hash and no create option, as shown in the Introduction. To work with several files, call requestFileHandle() 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.
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.requestFileHandle(
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 requestFileHandle() 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. A NotFoundError for a candidate can also mean the file is present but withheld by availability gating or GREASE'ing, which is why the loop must end in a real network fallback.
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 withoutorigins, 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: truerequest 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.requestFileHandle(hash);
worker.postMessage(handle);
// Cross-origin: throws `DataCloneError` on the receiving side.
otherOriginFrame.postMessage(handle, 'https://other.example');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 requestFileHandle() 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 |
All four are keyed off the same origins-style value space used by requestFileHandle(): omitted or empty for same-site only, a list of origins for a specific set of origins, or * for global availability. 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. 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 requestFileHandle().
<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. As in the JavaScript and CSS forms, the integrity hash identifies the file in COS, and crossoriginstorage specifies which origins may retrieve it.
A valueless crossoriginstorage attribute means same-site only, mirroring an omitted origins in the imperative API; * makes the resource globally available; and a space-separated list of origins restricts it to those origins, mirroring the origins array:
<!-- Same-site only. -->
<link
rel="stylesheet"
href="same-site-css-framework.css"
integrity="sha256-abc123..."
crossoriginstorage
/>
<!-- Globally available. -->
<script
src="popular-js-framework.js"
integrity="sha256-def456..."
crossoriginstorage="*"
></script>
<!-- Restricted to specific origins. -->
<script
src="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.
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. As with the HTML and CSS forms, integrity identifies the file in COS, and crossOriginStorage specifies which origins may retrieve it.
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.
An empty string means same-site only, "*" makes the module globally available, and a space-separated list of origins restricts it to those origins, mirroring the crossoriginstorage attribute in the HTML integration:
// Same-site only.
import sameSite from "same-site-resource.ext" with {
integrity: "sha256-abc123...",
crossOriginStorage: "",
};
// Globally available.
import popular from "popular-resource.ext" with {
integrity: "sha256-abc123...",
crossOriginStorage: "*",
};
// Restricted to specific origins.
import corporate from "acme-inc-corporate.ext" with {
integrity: "sha256-def456...",
crossOriginStorage: "https://acme-inc.example https://acme-cdn.example",
};The same attributes work with dynamic import():
const module = await import("popular-resource.ext", {
with: {
integrity: "sha256-abc123...",
crossOriginStorage: "*",
},
});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. The hash from integrity() identifies the file in COS, and cross-origin-storage() specifies which origins may retrieve it, mirroring the origins option in the JavaScript API.
cross-origin-storage() = cross-origin-storage( [ '*' | <string># ]? )
No arguments means same-site only, * makes the font globally available, and a list of origins restricts it to those 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(
"same-site-corporate.woff2"
integrity("sha256-abc123...")
cross-origin-storage()
);
}
/* Globally available. */
@font-face {
font-family: "Popular Emoji Font";
src: url(
"https://example.com/popular-emoji.woff2"
integrity("sha256-xyz789...")
cross-origin-storage(*)
);
}
/* Restricted to specific origins. */
@font-face {
font-family: "ACME Inc Corporate Font";
src: url(
"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.
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. As in the other three forms, the integrity hash identifies the file in COS, and crossOriginStorage specifies which origins may retrieve it. This is proposed to the WHATWG in whatwg/fetch#1954, where it would be defined as:
partial dictionary RequestInit {
(DOMString or sequence<DOMString>) crossOriginStorage;
};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, mirroring the values the imperative origins option accepts:
// Same-site only, mirroring an omitted `origins` in the imperative API.
const sameSite = await fetch('same-site-resource.ext', {
integrity: 'sha256-abc123...',
crossOriginStorage: '',
});
// Globally available.
const global = await fetch('popular-resource.ext', {
integrity: 'sha256-abc123...',
crossOriginStorage: '*',
});
// Restricted to specific origins.
const restricted = await fetch('acme-inc-corporate.ext', {
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.
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 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.
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('module.wasm', {
integrity: 'sha256-abc123...',
crossOriginStorage: '*',
}),
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
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.
Two questions are specific to this integration and need answers in the Fetch Standard discussion:
- Response fidelity on a cache hit. A COS entry carries bytes only, with no MIME type, status, or headers, deliberately so (see Storing the original URL as part of a COS entry for why unverifiable metadata stays out). A
Responsesynthesized from a hit therefore has noContent-Typeunless the integration invents one. The three other integrations sidestep this because the element, the module type, or the CSS property defines the destination, whereas a barefetch()has none. This matters concretely:WebAssembly.instantiateStreaming()refuses anything that is notapplication/wasm, which is exactly why the hand-written example above has to supply that header itself. Candidate answers include deriving the type from the request's destination, letting the caller declare it, or storing a user-agent-computed type alongside the bytes. - Header stripping. A response served from COS must not reveal whether the bytes came from storage or from the network, so it cannot carry the response headers of a fetch that never happened. As a privacy matter this is smaller than it first appears: cache hits are timing-observable regardless, and disclosure is already gated by
origins, the Public Hash List, and GREASE'ing on the read step, so this integration discloses no more thanrequestFileHandle()does. The open question is one of fidelity: whichstatus,Content-Length, andtypea hit-servedResponseshould report.
The HTML, import attribute, CSS, and fetch forms above share the same underlying model as the imperative API: a resource is identified by its integrity hash, and a COS lookup is attempted before falling back to the network.
- The user agent checks COS for a file matching the
integrityhash. If found and the requesting origin is allowed per the declaredorigins-style value, the resource is served from COS, and no network request is made. - Otherwise, the resource is fetched from the declared URL as usual. If the fetched content matches the
integrityhash and the declared origins permit it, the user agent stores it in COS for future use by this or other origins. If the hash does not match, the resource is rejected per existingintegritybehavior and is not stored in COS.
Step 1's COS lookup is subject to the same availability gating as the imperative API. A resource declared with the global (*) origins-style value is only found by a requester outside its storing origins if its hash also clears the Public Hash List (and GREASE'ing doesn't suppress it); a same-site- or list-scoped resource needs no such additional clearance once the requesting origin is in scope. Either way, a lookup that doesn't succeed simply falls through to step 2's network fetch, which makes it indistinguishable from a genuine cache miss, exactly as requestFileHandle()'s NotFoundError is.
Because all four forms piggyback on integrity, they inherit its existing failure semantics: a hash mismatch is always treated as a fetch failure, independent of whether COS is involved.
Note
The hash format differs between these four integrations and the imperative form, intentionally so. The integrity attribute, the integrity import attribute, the integrity() CSS modifier, and the integrity request option all follow the Subresource Integrity convention and express hashes as base64-encoded strings (e.g., sha256-abc123…). The imperative requestFileHandle() API uses lowercase hexadecimal strings (e.g., 8f434346…), which matches the format used by AI model hubs such as Hugging Face when publishing model checksums. The user agent normalizes both representations internally; they identify the same underlying bytes.
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. 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',
};If two tabs both check COS for the same file, find it absent, and begin downloading, the user agent may receive two concurrent writes for the same hash. The user agent stores the file once; the duplicate download is accepted as an edge-case cost. This proposal does not prescribe coordination between tabs for this scenario.
While an entry exists but has not yet been fully written (for example, while one of those concurrent writes is still in flight), any requestFileHandle() call for that hash, from any origin, including the origin currently writing it, rejects with a NotAllowedError (see the "Created, not yet written" row of the read path table). This distinct error keeps a reader from mistaking an in-progress write for a genuine cache miss, which would reject with NotFoundError, and starting a redundant, concurrent download of a file that may already be gigabytes into being written.
This also applies to a handle you already hold: calling getFile() on a FileSystemFileHandle obtained from a create: true request rejects with that same NotAllowedError until that very handle's write()/close() has resolved, even for the origin that requested the handle. Call getFile() only after the write has completed.
If a write fails (for example, its bytes don't hash to the requested value), the entry it was writing to is cleaned up: once no other write for that same hash is still outstanding, the user agent removes the entry, and a subsequent requestFileHandle() call for that hash gets an ordinary NotFoundError. This is why the "no other write still outstanding" qualifier matters: if two tabs are racing to write the same hash and one supplies the wrong bytes while the other is still in flight (or has already succeeded), the failing tab's cleanup must not disturb the other tab's write. The user agent tracks this per entry so that a failure only ever cleans up after itself. An already-written entry is never affected by this at all: no later failed write, from any origin, can remove an entry that some origin has already successfully stored.
requestFileHandle() returns an ordinary FileSystemFileHandle, so it arrives carrying the whole File System Standard surface, including operations that mean nothing for an entry that has no name, no containing directory, and no identity beyond its hash. This proposal settles two of them explicitly, so implementations behave the same.
isSameEntry() answers. Content-addressability means the registry holds at most one entry per hash, so two handles address the same entry exactly when their hashes match. That makes this the one identity question a COS handle can always answer, and refusing it the way move() and remove() are refused would discard information the user agent already holds. Comparing against a handle the calling origin did not obtain, or one belonging to a different file system, rejects. Returning false there would assert the two are different entries, which is a claim about a handle the caller is not entitled to inspect.
name is the hash, which follows from the File System Standard. The File System Standard defines name as the last path component of the handle's locator path, so all COS had to say is what that path is: a single item holding the entry's hash. An entry has no name of its own, and the hash is the only identity it has, so reporting it carries information the empty string would discard.
move() and remove() exist, and reject. An entry is shared by every origin that stored it, so honoring a removal would let one site destroy data other sites depend on; deletion belongs to eviction and to the user's own storage controls. A rename has nothing to act on. Neither method is defined by the File System Standard or the File System Access API today (WICG/file-system-access#214), so the error name follows removeEntry() (the operation the standard does define for deleting an entry), which rejects with NotAllowedError when readwrite access is not granted.
Permission queries never say prompt, and say denied for writing without create. Handles are pre-authorized, so no prompt can appear. Writing is the one capability a handle can genuinely lack: only a create request yields a writable handle, so reporting granted for a write mode on any other handle would claim a capability createWritable() will refuse. Requesting cannot change the answer: there is no prompt to show and nowhere to record a grant.
createSyncAccessHandle() was never ours to decide. Its File System Standard steps reject with InvalidStateError whenever the handle is not in a bucket file system, and the COS file system is a root distinct from any origin's, so the standard already fixes both the refusal and the error name. That is the outcome we would want anyway: a sync access handle hands the caller a writable file descriptor, which would let it change an entry's bytes out from under the hash they are stored against. Every other origin the entry is disclosable to would then read the tampered bytes.
createWritable({keepExistingData: true}) still starts empty. The File System Standard defines that option as seeding the stream with the file's current contents, and honoring it here would be an availability-gating bypass. A create request hands back a handle whether or not the entry already exists, so a caller could open a writable for a hash some other origin stored, close it having written nothing, and let the carried-over bytes hash to the requested value. It would become a storing origin, and gain read access, for bytes it never possessed, with origins, the Public Hash List, and GREASE'ing never consulted. It is the same reasoning that makes every create request supply the complete contents. A writable closed without any write therefore holds the empty byte sequence and fails verification with DataError, like any other mismatch; reporting a storage or I/O error there would hide a verification result behind an unrelated failure.
A CrossOriginStorageManager is keyed to the origin of the context it lives in, which for a worker is the origin of that worker's environment settings object, independent of its script URL. Two worker kinds that look similar therefore behave very differently, and it is worth knowing which is which before reaching for one.
A worker created from a blob: URL has the origin of the context that created it. It shares one COS view with that page: what the worker writes, the page can read back, and what the page stored, the worker can read. The blob: URL is not an origin of its own, and revoking it does not detach the worker from that view. A worker's writes are consequently indistinguishable from its creating page's own writes, which is the intended behavior but is easy to misread as isolation.
That sharing is also why the Permissions Policy check is stated over the calling global. Permissions Policy is defined for documents, and a worker global has none of its own, so a dedicated or shared worker may use COS only if every document in its owner set may, resolved transitively through any intervening workers. Without that, Permissions-Policy: cross-origin-storage=() would be an opt-out in name only: moving the call into new Worker(URL.createObjectURL(blob)) would escape it while, per the paragraph above, still sharing the page's exact COS view. A service worker has no owner set at all, so COS is unavailable there for now. That follows from what an owner set is: a liveness relation used to decide whether a worker is still actively needed, and a service worker deliberately has none, since its registration is persisted and the browser starts it in response to events, often with no client in existence. Covering it needs a captured policy, most plausibly a Permissions-Policy header on the service worker's own script response, and that work belongs to the Permissions Policy and Service Workers specifications.
A worker created from a data: URL has an opaque origin. An opaque origin has no stable identity to key storing origins or same-site comparisons on, so there is nothing meaningful to grant it. This proposal does not currently define what requestFileHandle() does for a caller whose own origin is opaque. The opaque-origin rule in "validate a COS request" governs only the origins a caller names in origins. Implementations should at minimum reject such calls promptly, so the returned promise never stays pending.
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.
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.
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.
A related idea is to record, on each COS entry, the URL the file was originally fetched from. It is tempting for much the same reason a description is: it would make a multi-gigabyte blob legible in the browser's storage UI, and it would help a developer debug a requestFileHandle() miss. It does not fit the model, for three reasons:
- It isn't a property of the entry. A COS entry is shared by every origin that stores those bytes, and the URL belongs to one writer's fetch. Ten origins may store the same file from ten different URLs. First-writer-wins is arbitrary, since the first storer is privileged in no other respect, and keeping a set of URLs grows without bound and can be polluted by any origin that writes the bytes.
- It can't be verified. Bytes are checked against the hash; a URL string is merely a claim by whoever wrote them, and an origin can claim any URL. Placing an unverifiable label inside an otherwise fully verified structure invites it to be read as provenance when it is nothing of the sort.
- It leaks far more than an origin does. COS's disclosure limits (
origins, availability gating, and the Public Hash List) are calibrated around coarse, origin-level information. A full URL can carry paths, query parameters, tokens, and user identifiers (https://cdn.example/models/user-1234/weights.bin), which is a much higher-entropy signal than that calibration accounts for.
The adjacent motivations do not survive scrutiny either. Re-fetching after eviction would mean the user agent issuing a request to a third party's URL with ambient authority, and the calling origin already knows its own URL and can simply fetch it again. Attribution in permission UI does not need it, since the user agent already has the requesting origin at prompt time, and a historical, spoofable URL recorded by some other site would mislead. Popularity corroboration belongs to Public Hash List admission, which happens offline, once for everyone.
What remains is the debugging and storage-inspection motivation, and that needs no web-exposed field. A user agent is free to keep an implementation-private provenance record (say, the URL each storing origin fetched the bytes from, and when), as long as it is treated as browser state kept outside the entry:
- It is never exposed to script by any COS API, and a requesting origin cannot observe a record it did not itself produce.
- It is surfaced only through trusted, non-web surfaces: the browser's own settings and storage inspection UI, developer tools, and extension APIs gated behind an explicit, user-granted permission, on the same footing as other APIs that expose browsing history. See Browser extension integration points for COS for the extension surfaces under consideration.
- It is presented as an unverified claim by the writing origin. Only the hash guarantees the content. Two origins may record different URLs for the same entry.
- It is discarded with the entry, and per origin when that origin is removed from the entry's storing origins, including when the user clears that origin's site data.
Because such a record is invisible to content, a site cannot detect whether the browser keeps one, so this stays purely a matter of implementation quality of life. See Provenance metadata in the spec.
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.
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.
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.
COS is reachable from fetch() (see Fetch integration), so the question is whether fetch() should be the only way to reach it, with navigator.crossOriginStorage.requestFileHandle() dropped in favor of a RequestInit option. That was considered and rejected, because the two express different things: a fetch couples naming a resource to downloading it, while the imperative API keeps those steps separate.
Bytes reach COS from places fetch() does not own. Managing downloads is explicitly out of scope for this proposal (see Non-goals), and in practice the bytes stored in COS often did not come from one fetch() call. They may arrive from a Background Fetch, from Range requests for a sharded resource that the site reassembles itself, from a file the user picked off their local disk, or from another storage API entirely. The sharded case cannot be expressed through a fetch integration at all, because the COS entry is a shard that no single URL serves.
A read may have no URL to offer. A lookup that only asks whether COS already holds a given hash has no URL attached, and the caller may have nothing to download if the answer is no. The motivating case is AI models, which ship as families of interchangeable variants: an app built around whisper-tiny should transcribe with whisper-large-v3 if the user already has it, and skip downloading a smaller, worse model on top of a better one already on the device. Expressing that means probing several hashes and committing to a download only after all of them come back empty, as shown in Choosing among interchangeable resources. A fetch-shaped API cannot ask this question, since every probe would have to name a URL the app has no intention of fetching, and a probe whose whole purpose is to avoid a network request would be spelled as a request.
Handles are not responses. A FileSystemFileHandle can be transferred to another context, reused across several reads, and written through with the same File System Standard machinery developers already use for OPFS. A Response is a single, one-shot consumption of a body. Store-only writes in particular have no natural spelling in fetch(): there is no request to make, only bytes to hand over.
The imperative API is therefore the general surface, and the four host integrations are ergonomic shortcuts for the common special case where a URL and a hash are both known up front and the bytes are wanted immediately.
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.
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.
See the complete questionnaire for details.
Access is scoped to individual files, each identified by their hash. Developers cannot arbitrarily access any random files or obtain the complete list of resources in COS, ensuring limited and precise access control. Files are uniquely identified by their cryptographic hashes (for example, SHA-256), ensuring data integrity. Hashes prevent tampering with the file contents, that is, a site can be sure it gets the same contents from COS as if it had downloaded the file itself, as COS guarantees that each file's contents matches its hash. For enhanced protection, user agents can check file hashes against virus databases like VirusTotal, and integrate with in-browser security features like Safe Browsing even before storing a file.
Users can inspect, evict, and clear COS files through the user agent's settings UI; see Eviction.
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 origins list form lets a writer disclose bytes it holds to origins it does not control. That disclosure decision is expressed in script (the origins option) or in markup (the crossoriginstorage attribute and its siblings), both of which an attacker who has achieved script or markup injection on the writing origin can set. Without a further control, an injected script could take data it can already read on the compromised page, write it into a list-scoped COS entry naming an attacker-controlled origin, and let that origin read it back later from its own top-level context.
What makes this worth a dedicated mitigation is that the COS write produces no network egress at the moment of compromise: the bytes are written to local storage, and the read happens later, from a different origin, in traffic that looks unrelated to the victim. A site that relies on egress monitoring, a connect-src allowlist, or a CSP report endpoint to contain a compromise sees a clean page load. CSP itself was never an exfiltration boundary: an attacker who merely wants the data to leave the page already has simpler channels that CSP does not close, such as a top-level navigation to an attacker URL.
The mitigation is to require that a non-same-site origins declaration be authorized by a response header the injected content cannot forge:
- For the imperative API, whose bytes may be generated in script and have no originating response, the authority is the writing document's own response, which carries a
Cross-Origin-Storage-Allow-Originheader enumerating the origins its scripts may name. - For the four host integrations, whose bytes come from a fetched resource, the authority is that resource's response header. This also fixes an unrelated correctness gap: without it, any site could unilaterally declare someone else's asset shareable to a list of its choosing.
Both cases reduce to the same rule: whoever supplies the bytes sends the header, and the declared list is intersected with that ceiling. An origin the ceiling does not name is dropped; if that leaves no cross-origin recipient, the write still succeeds and falls back to the same-site default. The check is evaluated per writer, so a later writer can only add origins its own response authorizes, and cannot rewrite an earlier writer's scope.
The '*' form needs no such header: a '*'-scoped resource is disclosed to a non-storing origin only if its hash is on the Public Hash List, which a per-user secret can never reach, so declaring a secret '*' discloses nothing. The same-site default needs no header either, since it names no cross-origin recipient. The header is therefore required for, and only for, the list form.
One limit is worth stating plainly. The ceiling bounds disclosure to the origins the operator pre-authorized in the header; it does not reduce it to zero. If a site legitimately shares data to https://partner.example and is then compromised, the injected script can still reach https://partner.example, because the operator authorized it. This is the same guarantee connect-src gives: the attack surface shrinks from any origin on the web to the operator's declared partners, which for a genuine multi-property deployment is a small, trusted set.
In browsers that still support third-party cookies, user agents are expected to make this API available only in contexts where third-party cookies are enabled.
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 "specific set of trusted origins" stays meaningfully smaller than the web. Nothing about the shape of origins stops a caller from enumerating a very large number of origins (for example, a list assembled from a public top-sites ranking), which would functionally approximate global disclosure while bypassing the deliberate, explicit opt-in that origins: '*' alone requires. This is why origins lists have an implementation-defined maximum length (see Storing files): a limit small enough to fit genuine multi-property use cases (a handful of related origins under common control) but far short of any meaningful approximation of "every origin". A second control constrains which origins a list may name: the list is bounded by a Cross-Origin-Storage-Allow-Origin response header, so a caller cannot name origins the byte-supplying origin's operator did not authorize, even ones injected into an otherwise honest page (see The Cross-Origin-Storage-Allow-Origin header).
User agents are expected to implement safeguards against such attacks, for example, by limiting the number of probes, or by returning false negatives when a site known to be malicious is probing. Each call to requestFileHandle() can be considered a probe, and user agents can limit the number of probes per site or even block probes from sites known to be malicious.
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 requestFileHandle() is, so counting only imperative calls would leave the limit trivially avoidable.
Two independent mechanisms can control whether a requestFileHandle() call returns a file handle or a NotFoundError:
- Access control (
origins-based): which origins may obtain a file handle. This is determined by the grants set at write time: the explicitoriginslist, plus the always-present storing-origin and same-site baseline. An origin that qualifies under none of them receivesNotFoundError, even if the resource is physically present in COS. - Availability gating (PHL-based): whether the user agent discloses that the resource exists in COS at all. This applies only to the global (
origins: '*') grant. It is determined by whether the hash is on the Public Hash List (PHL), a shared, vendor-neutral allowlist that all browser vendors are expected to respect. An origin relying on the global grant to reach a resource whose hash is not on the PHL receivesNotFoundError, even though'*'nominally permits any origin.
The two mechanisms attach to individual grants, and grants are additive, so an entry can carry both. A requester that qualifies through the storing-origin, same-site, or explicit-list grant is subject to access control only, and succeeds without the hash being on the PHL, even if the same entry is also globally disclosable. Only a requester relying on the global grant alone is subject to availability gating as well, and then both must be satisfied: a globally disclosable resource whose hash is not on the PHL is not cross-origin accessible through that grant. This split is deliberate: the storing origin has already made an explicit, bounded disclosure decision by naming a specific list or accepting the same-site default, so requiring separate global-ubiquity clearance on top of that would make ordinary restricted sharing (see Choosing who can read a file) depend on unrelated, public curation of what is often a proprietary resource that will never appear on a public allowlist. Availability gating exists specifically to bound the global grant, the one grant where disclosure could otherwise reach any origin on the web.
Availability gating in detail. For a '*'-scoped resource, user agents implement availability gating using the PHL:
- On the PHL: The user agent may answer truthfully, returning a handle if the file is present, or a
NotFoundErrorDOMExceptionif it is absent. (GREASE'ing may still introduce occasional false negatives even for PHL-listed resources.) - Not on the PHL: The user agent must always return a
NotFoundErrorDOMException, regardless of whether the file is physically present in COS. The response must be identical whether the file is absent or present, so that cache state cannot be inferred by observing the response or its timing.
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. A hash is either on the current PHL snapshot or it isn't; a hash that never clears that bar is treated as permanently absent at the API surface, and the user agent returns a NotFoundError DOMException as if the file were not stored in COS at all.
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 rely on a NotFoundError as definitive proof that a file is absent from COS. A NotFoundError MAY indicate that the requesting origin is simply out of scope, or, for a '*'-scoped resource, that the user agent has withheld confirmation of the file's presence for privacy reasons.
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, their same-site origins, and origins on an explicit origins list 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.
The following tables summarize the response a user agent must return for every combination of inputs. Outside of the "Created, not yet written" case below, every non-success read-path outcome returns NotFoundError, so the caller cannot distinguish between a genuine cache miss and a gated or access-controlled resource.
The rows are keyed by how the requesting origin qualifies, and the grants are additive, so a requester that qualifies under any row succeeds under that row regardless of the others. In particular, an origin on the explicit list succeeds without the PHL even when the entry is also globally disclosable.
| Requester qualifies via | On PHL? | GREASEd? | Response |
|---|---|---|---|
| (entry created, not yet written) | — | — | NotAllowedError |
| Storing origin | — | — | Success |
| Same-site of a storing origin | — | — | Success |
On the explicit origins list |
— | — | 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 |
The PHL is consulted only for an origin that qualifies solely through the global grant; the storing-origin, same-site, and explicit-list rows never consult it, which is why their "On PHL?" cells show "—". The same holds for GREASE'ing: it can turn only a success through the global grant into a NotFoundError, so those rows show "—" under "GREASEd?" too. A storing origin always succeeds, independent of PHL, origins, or GREASE'ing (see Original storer access).
The "Created, not yet written" row applies both to a fresh requestFileHandle() call for that hash and to calling getFile() on a FileSystemFileHandle that was itself obtained from a still-pending create: true request; see Concurrent writes.
getFile() is gated per handle, so a handle obtained from a create: true request also rejects with NotAllowedError when some other origin has already written the entry and this handle has not been written through. 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.
| 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 |
| Permissions Policy blocks COS | Any | NotAllowedError |
| Valid hash, declared hash matches computed hash | * |
Success |
| Valid hash, declared hash matches computed hash | Same-site or list | 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) |
| 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 |
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.
- 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.
- Public Hash List explainer
- File System Living Standard
- Web Cryptography API
- Subresource Integrity
- Import Attributes
- CSS Values and Units Module Level 5
- Fetch Living Standard
- Cache Digests for HTTP/2
- Web Sustainability Guidelines (WSG)
- Ethical Web Principles
Many thanks for valuable feedback from:
- Tab Atkins-Bittner, Google Chrome
- Yash Raj Bharti, Google Cloud
- Joshua Lochner, Hugging Face
Many thanks for valuable inspiration or ideas from:
- Kenji Baheux, Google Chrome
- Kevin Moore, Google Chrome
Copied from the formal spec on every commit.
[Exposed=(Window,Worker), SecureContext]
interface CrossOriginStorageManager {
Promise<FileSystemFileHandle> requestFileHandle(
CrossOriginStorageRequestFileHandleHash hash,
optional CrossOriginStorageRequestFileHandleOptions options = {});
};
dictionary CrossOriginStorageRequestFileHandleHash {
required DOMString value;
required DOMString algorithm;
};
dictionary CrossOriginStorageRequestFileHandleOptions {
boolean create = false;
(DOMString or sequence<DOMString>) origins;
};
interface mixin NavigatorCrossOriginStorage {
[SameObject, SecureContext] readonly attribute CrossOriginStorageManager crossOriginStorage;
};
Navigator includes NavigatorCrossOriginStorage;
WorkerNavigator includes NavigatorCrossOriginStorage;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);
});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 requestFileHandle() (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 requestFileHandle(hash, options), modeled directly on the File System Standard's FileSystemDirectoryHandle.getFileHandle(), makes the common case clean and readable. Where getFileHandle() takes a name, requestFileHandle() 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.requestFileHandle(hash))) gives better per-file error granularity than a batched call would anyway.