- Ayu Ishii - [email protected]
- Benjamin C. Wiley Sittler
- Marijn Kruisselbrink - [email protected]
- Staphany Park - [email protected]
- Victor Costan - [email protected]
- Introduction
- Use Cases
- The Query API
- The Modifications API
- The Change Events API
- Security Model
- Subtleties
- Related Work
This proposal has the following main goals.
- Expose HTTP cookies to service workers.
- Offer an asynchronous alternative to
document.cookie
.
While accomplishing the goals above, the proposal gained the following nice-to-have properties.
-
Cookies expose the parsed components of the cookie-related HTTP headers. Script does not need to parse or serialize HTTP headers.
-
The APIs have a well-defined mechanism for reporting cookie storage errors. Script does not need to issue a read request to verify the result of the previous write request.
-
The APIs side-step some of the known cross-browser incompatibilities, and differences between specification and browser behavior.
The proposal does not aim to change the cookie security model, or how cookies are handled at the network layer.
The proposal is also known as the Async Cookies API.
Cookies are used most often for authentication. In this case, the relevant cookies are generally called session cookies, and the state embedded in them tends to be called session state. Documents generally update the current page UI in response to changes in the session state. Service workers also need to react to session state changes, to clean up private cached data.
Cookies have also found a niche in storing user decisions to opt out of tracking by ad networks, and receive less personalized ads.
Separately, from a conceptual angle, a service worker is intended to be an HTTP proxy for the pages under its scope. By this principle, service workers must be able to read and modify the cookies accessible to pages under their scopes.
The following code illustrates synchronous polling via document.cookie
. The
code periodically induces jank, as document.cookie
is a synchronous call
that blocks the main thread on disk I/O, if the cookie value isn't cached in
memory, and/or on IPC, if the cookie cache does not reside in the same
process as the Document execution context.
function decode_document_cookie(value) {
// Simplified version of the code at https://github.com/js-cookie/js-cookie.
const cookie_strings = value.split('; ');
const cookies = {};
for (const cookie_string of cookie_strings) {
const index = cookie_string.indexOf('=');
const name = cookie_string.substring(0, index);
const encoded_value = cookie_string.substring(index + 1);
cookies[name] = decodeURIComponent(encoded_value);
}
return cookies;
}
let old_value = null;
function poll(duration_ms, cookie_name, handle_cookie_change) {
const cookies = decode_document_cookie(document.cookie);
const newValue = (cookie_name in cookies) ? cookies[cookie_name] : null;
if (old_value !== new_value) {
handle_cookie_change(new_value);
old_value = new_value;
}
setTimeout(() => {
poll(duration_ms, cookie_name, handle_cookie_change);
}, duration_ms);
}
The following code snippet uses the Cookie Store API instead of
document.cookie
. The Cookie Store API doesn't block the main thread, so this
version does not introduce jank.
let old_value = null;
async function poll(duration_ms, cookie_name, handle_cookie_change) {
while (true) {
const cookie = await cookieStore.get(cookie_name);
const new_value = cookie ? cookie.value : null;
if (new_value !== old_value) {
handle_cookie_change(cookie_name, new_value);
old_value = new_value;
}
await delayedPromise(duration_ms);
}
}
The following code snippet uses change events in the Cookie Store API. This has less overhead than asynchronous polling when the session cookies change rarely.
function poll(cookie_name, handle_cookie_change) {
cookieStore.addEventListener('change', (event) => {
for (const cookie of event.changed) {
if (cookie.name === cookie_name)
handle_cookie_change(cookie.value);
}
for (const cookie of event.deleted) {
if (cookie.name === cookie_name)
handle_cookie_change(null);
}
});
}
Last, the following code snippet uses the service worker change events in the Cookie Store API. The change handler will be executed even if the site isn't currently opened in a browser tab / window.
const kCookieName = 'session';
self.addEventListener('activate', (event) => {
event.waitUntil(async () => {
await self.registration.cookies.subscribe([{ name: kCookieName }]);
});
});
self.addEventListener('cookiechange', (event) => {
for (const cookie of event.changed) {
if (cookie.name === kCookieName)
handle_session_change(cookie.name, cookie.value);
}
for (const cookie of event.deleted) {
if (cookie.name === kCookieName)
handle_session_change(cookie.name, null);
}
});
The following code snippet illustrates a solution based on the synchronous
document.cookie
settter. This induces jank, as the setter must block the
main thread until the cookie change is propagated to the network stack,
because Web developers expect that the cookie change would be reflected in a
fetch
API call following the assignment to document.cookie
.
document.getElementById('opt-out-button').addEventListener('click', () => {
document.cookie = 'opt_out=1; Expires=Wed, 1 Jan 2025 00:00:00 GMT; Secure';
});
document.getElementById('opt-in-button').addEventListener('click', () => {
// Cookies are deleted by setting their expiration dates in the past.
document.cookie = 'opt_out=0; Expires=Thu, 1 Jan 1970 00:00:00 GMT; Secure';
});
The following code snippet uses the Cookie Store API instead, and does not jank the main thread.
document.getElementById('opt-out-button').addEventListener('click', async () => {
await cookieStore.set({ name: 'opt_out', value: '1',
expires: new Date('Wed, 1 Jan 2025 00:00:00 GMT') });
});
document.getElementById('opt-in-button').addEventListener('click', async () => {
await cookieStore.delete({ name: 'opt_out' });
});
Both documents and service workers access the same query API, via the
cookieStore
property on the global object.
All methods in the query API return Promises.
try {
const cookie = await cookieStore.get('session_id');
if (cookie) {
console.log(`Found ${cookie.name} cookie: ${cookie.value}`);
} else {
console.log('Cookie not found');
}
} catch (e) {
console.error(`Cookie store error: ${e}`);
}
try {
const cookies = await cookieStore.getAll('session_id');
for (const cookie of cookies)
console.log(`Result: ${cookie.name} = ${cookie.value}`);
} catch (e) {
console.error(`Cookie store error: ${e}`);
}
get
is essentially a form of getAll
that only returns the first result.
In Document contexts, await cookieStore.getAll()
is an equivalent of
document.cookie
.
In other words, get
and getAll
take the same arguments, which can be
- a name
- a dictionary of options (optional for
getAll
)
Cookies are URL-scoped, so fetches to different URLs may include different cookies, even when the URLs have the same origin. The application can specify the URL whose associated cookies will be read.
await cookieStore.getAll({url: '/admin'});
Service workers can obtain the list of cookies that would be sent by a fetch to any URL under their scope.
Documents can only obtain the cookies at their current URL. In other words,
the only valid url
value in Document contexts is the document's URL.
The cookie objects will have a boolean value indicating if the cookie is partitioned.
// Read a cookie set without the Partitioned attribute.
const cookie = await cookieStore.get('session_id');
console.log(cookie.partitioned); // -> false
// Read a Partitioned cookie from a third-party context.
const cookie = await cookieStore.get({
name: '__Host-third_party_session_id',
partitioned: true
});
console.log(cookie.partitioned); // -> true
Both documents and service workers access the same modification API, via the
cookieStore
property on the global object.
try {
await cookieStore.set('opted_out', '1');
} catch (e) {
console.error(`Failed to set cookie: ${e}`);
}
The cookieStore.set()
call above is a shorthand for the following.
await cookieStore.set({
name: 'opted_out',
value: '1',
expires: null, // session cookie
// By default, domain is set to null which means the scope is locked at the current domain.
domain: null,
path: '/'
});
If the user agent supports cookie partitioning then you can set a partitioned cookie in a third-party context using the following.
await cookieStore.set({
name: '__Host-third_party_session_id',
value: 'foobar',
path: '/',
sameSite: 'none',
partitioned: true
// `Secure` is implicitly set
});
try {
await cookieStore.delete('session_id');
} catch (e) {
console.error(`Failed to delete cookie: ${e}`);
}
The document.cookie
approach of deleting a cookie by moving the cookie's
expiration date to the past still works.
try {
await cookieStore.set({
name: 'session_id',
value: 'value will be ignored',
expires: Date.now() - 24 * 60 * 60 * 1000 });
} catch (e) {
console.error(`Failed to delete cookie: ${e}`);
}
If the user agent supports cookie partitioning then it is possible for a site to set both a partitioned and unpartitioned cookie with the same name.
To delete a partitioned cookie, the partitioned
parameter must be provided:
await cookieStore.delete({
name: '__Host-third_party_session_id',
partitioned: true
});
If the site wants to only delete the unpartitioned cookie, change the partitioned
field to false
or omit the property.
The objects returned by get
and getAll
contain all the information in the
cookie store, not just the name and the value.
const cookie = await cookieStore.get('session_id');
console.log(`Cookie scope - Domain: ${cookie.domain} Path: ${cookie.path}`);
if (cookie.expires === null) {
console.log('Cookie expires at the end of the session');
} else {
console.log(`Cookie expires at: ${cookie.expires}`);
}
if (cookie.secure)
console.log('The cookie is restricted to secure origins');
This proposal includes an API for observing cookie changes, which aims to
address all the use cases that currently require polling document.cookie
. The
API has the following steps:
- Express an interest in observing cookie changes
- Handle cookie change events
cookieStore.addEventListener('change', (event) => {
console.log(`${event.changed.length} changed cookies`);
for (const cookie of event.changed)
console.log(`Cookie ${cookie.name} changed to ${cookie.value}`);
console.log(`${event.deleted.length} deleted cookies`);
for (const cookie of event.deleted)
console.log(`Cookie ${cookie.name} deleted`);
});
TODO: Add JSON output for the relevant properties of the event.
The API is designed to allow browsers to batch change events, for performance reasons.
self.addEventListener('cookiechange', (event) => {
// The event has |changed| and |deleted| properties with
// the same semantics as the Document events.
console.log(`${event.changed.length} changed cookies`);
console.log(`${event.deleted.length} deleted cookies`);
});
Subscriptions can use the same options as cookieStore.get
/
cookieStore.getAll
. The complexity of fine-grained subscriptions is justified
by the cost of dispatching an irrelevant cookie change event to a service
worker, which is is much higher than the cost of dispatching an equivalent event
to a Document. Specifically, dispatching an event to a service worker might
require waking up the worker, which has a significant impact on battery life.
All service workers under the same registration operate on a single set of subscriptions that lives on the registration. This pattern is also seen in the Push API and Periodic Background Sync API, where push subscriptions and sync registrations, respectively, are aggregated under the service worker registration. Due to the need for permission prompts, however, these two APIs restrict registration changes to the window context, while the Cookie Store API allows subscription modifications from both the window and service worker contexts.
The main disadvantage of this pattern is that each service worker must account for subscriptions registered by other versions. To avoid cross-contamination, it is recommended to always wait until the 'activate' event before snapshotting or modifying cookie change subscriptions. Modifying the subscription state during installation could cause the currently active version to unexpectedly receive or drop cookie change events.
Calls to subscribe()
and unsubscribe()
are idempotent.
self.addEventListener('activate', (event) => {
event.waitUntil(async () =>
// Snapshot current state of subscriptions.
const subscriptions = await self.registration.cookies.getSubscriptions();
// Clear any existing subscriptions.
await self.registration.cookies.unsubscribe(subscriptions);
await self.registration.cookies.subscribe([
{
name: 'session_id', // Get change events for cookies named session_id.
}
]);
});
});
Alternatively, each service worker version could manage its own set of subscriptions. During installation, the service worker would set up its subscriptions; after activation, it would receive only change events that match those subscriptions.
The main advantage of this approach is that each service worker starts with a clean slate of subscriptions, rather than potentially carrying over subscriptions from previous service worker versions. Thus, a service worker's script contains all the information needed to know what subscriptions it has.
The main disadvantage of this approach is the loss of any change events dispatched while there is no active service worker. For example, even if two sequential service worker versions subscribe to the same change events, neither of them would see the change events dispatched in the window between deactivation of the first and activation of the second. If the second service worker version takes a snapshot of the cookie jar at install time, that snapshot could be outdated by the time the service worker becomes active.
This proposal aims to preserve the current security model for cookies. In most situations, this principle means deferring to RFC 6265bis.
The modification API can not be used to set HttpOnly cookies. Neither the query
nor the monitoring API will include HttpOnly cookies in their results. This
matches the behavior of document.cookie
.
The modification API sets the secure
(HTTPS-only) flag to true for
all origins. This is an intentional difference from document.cookie
,
which always defaults to insecure cookies.
The modification API disallows modifying (overwriting or deleting) cookies from a non-secure origin, following a recent proposal.
The modification API refuses to write a cookie whose name is the empty string
if its value contains =
. This avoids an ambiguous serialization problem in
some current browsers.
This proposal enforces the
special cookie name prefixes
__Host-
and __Secure-
. Specifically, the modification API rejects changes
where the name contains one of the special prefixes, but the domain
or
secure
options don't match the values implied by the special prefix.
The modification API serializes cookie names and values using UTF-8.
The guiding principle behind handling cookie scopes is providing equivalent
capabilities to the document.cookie
API. We interpret this as follows.
- Document contexts may perform (roughly) the same operations that
document.cookie
would allow. - A service worker may perform an operation if at least one document under its
scope would be able to use
document.cookie
to perform that operation. - The path in a scope is not considered reliable for security purposes. This
matches the stance in the
document.cookie
specification, which points out that<iframe>
s can be used to access the cookie list for different paths in the current origin.
This proposal intentionally diverges from document.cookie
in the handling of
paths, for the purpose of ending up with a simpler model.
The modification API
- defaults paths to
/
- rejects relative paths (not starting with
/
) - adds
/
to the end of the paths that don't end in/
Note that while cookies created by the modification API are guaranteed to have
absolute paths that are directories, the query and change event API must still
handle cookies created by HTTP headers and by the document.cookie
setter.
The modification API defaults to domain-less cookies, which are bound to the current origin.
The modification API accepts any domain that matches the current origin's
eTLD+1. This deviation from the same origin policy is unintuitive, but
matches the current document.cookie
capabilities. This proposal does not
aim to change this situation because the domain flexibility is an integral part
of authentication on the Web today.
TODO: Can we restrict domain
to the current origin's subdomains and
super-domains (up to eTLD+1)?
The query and change event APIs
- remove the last path segment from URLs that don't end in
/
- resolve relative URLs based on the current document / service worker's URL
The current document / service worker URL can only become
relevant for cookies created by document.cookie
or by HTTP responses.
Document contexts may only use the current document's URL into the query API. The change events API exposed to documents does not accept URLs.
Service workers may pass any URL under their scopes into the query or change event API.
The Cookie Store API does not change the access level for Service Workers.
Service Workers can currently access the cookies of any URL under their scope. For example, a Service Worker could respond to any top-level request with an HTML document embedding an <iframe>
pointing to the desired URL. When responding to the request for that URL, the Service Worker can respond with an HTML document containing a <script>
that proxies the Service Worker's access to the document.cookie
API using postMessage
.
This proposal side-steps date parsing issues by only accepting timestamps (milliseconds since UNIX epoch), like the lastModified attribute in the File API.
JavaScript Date objects will be automatically converted to timestamps thanks to implicit conversions.
This API defaults cookie paths to /
for cookie write operations, including deletion/expiration. The implicit relative path-scoping of cookies to .
has caused a lot of additional complexity for relatively little gain given their security equivalence under the same-origin policy and the difficulties arising from multiple same-named cookies at overlapping paths on the same domain. Cookie paths without a trailing /
are treated as if they had a trailing /
appended for cookie write operations. Cookie paths must start with /
for write operations, and must not contain any ..
path segments. Query parameters and URL fragments are not allowed in paths for cookie write operations.
URLs without a trailing /
are treated as if the final path segment had been removed for cookie read operations, including change monitoring. Paths for cookie read operations are resolved relative to the default read cookie path.
This API only allows writing Secure
cookies. This is intended to prevent unintentional leakage to unsecured connections on the same domain. Furthermore, it disallows (to the extent permitted by the browser implementation) creation or modification of Secure
flagged cookies from unsecured web origins and enforces special rules for the Host
and Secure
cookie name prefixes.
This API will, however, allow reading non-Secure
cookies in order to facilitate the migration to Secure
cookies.
This API defaults cookies to "Domain"-less, which in conjunction with "Secure" provides origin-scoped cookie
behavior in most modern browsers. When practical the __Host-
cookie name prefix should be used with these cookies so that cooperating browsers origin-scope them.
Serialization of expiration times for non-session cookies in a special cookie-specific format has proven cumbersome, so this API allows JavaScript Date objects and numeric timestamps (milliseconds since the beginning of the Unix epoch) to be used instead. The inconsistently-implemented Max-Age parameter is not exposed, although similar functionality is available for the specific case of expiring a cookie.
Cookies without U+003D (=) code points in their HTTP Cookie header serialization are treated as having an empty name, consistent with the majority of current browsers. Cookies with an empty name cannot be set using values containing U+003D (=) code points as this would result in ambiguous serializations in the majority of current browsers.
Internationalized cookie usage from scripts has to date been slow and browser-specific due to lack of interoperability because although several major browsers use UTF-8 interpretation for cookie data, historically Safari and browsers based on WinINet have not. This API requires UTF-8 interpretation of cookie data and uses USVString
for the script interface,
with the additional side-effects that subsequent uses of document.cookie
to read a cookie read or written through this interface and subsequent uses of document.cookie
to update a cookie previously read or written through this interface will also use a UTF-8 interpretation of the cookie data. This mandates changes to the behavior of WinINet
-based user agents and Safari but should bring their behavior into concordance with other modern user agents.
Some user-agents implement non-standard extensions to cookie behavior. The intent of this API is to first capture a useful and interoperable (or mostly-interoperable) subset of cookie behavior implemented across modern browsers. As new cookie features are specified and adopted it is expected that this API will be extended to include them. A secondary goal is to converge with document.cookie
behavior
and the http cookie specification. See whatwg/html#804 and https://inikulin.github.io/cookie-compat/
for the current state of this convergence.
Tutorials on this feature might want to cover the following topics.
The expiration date is an absolute timestamp, not a relative time interval. If users of this API pass in a small non-zero number for expires
, e.g. expires: 30
or expires: 1000
, the Cookie Store API will delete the cookie data, resulting in data loss.
For example, this code sample might superficially appear to set a cookie that lasts for a year, but instead it sets the cookie to expire at midnight of 1971 UTC, which will delete the cookie and its data immedately.
BAD
try {
await cookieStore.set({
name: 'session_id',
value: 'this data will be LOST',
expires: 365 * 24 * 60 * 60 * 1000 });
} catch (e) {
console.error(`Failed to set cookie: ${e}`);
}
GOOD
try {
await cookieStore.set({
name: 'session_id',
value: 'this cookie lasts for a year',
expires: Date.now() + 365 * 24 * 60 * 60 * 1000 });
} catch (e) {
console.error(`Failed to set cookie: ${e}`);
}
Cookie store operations are guaranteed to have completed when their promises are resolved. Failure to await the results can lead to the following races.
BAD
await cookieStore.set('cookie-name', 'old');
cookieStore.set('cookie-name', 'new'); // Missing "await".
// The cookieStore.set and the fetch operations are racing.
// The beacon may include cookie-name=old or cookie-name=new.
navigator.sendBeacon('/analytics');
GOOD
await cookieStore.set('cookie-name', 'old');
await cookieStore.set('cookie-name', 'new');
// The beacon is guaranteed to include cookie-name=new.
navigator.sendBeacon('/analytics');
BAD
await cookieStore.delete('session-id');
fetch('/auth'); // The response includes the HTTP header Cookie: session-id=new.
const cookie = await cookieStore.get('session-id');
// The fetch response processing and the cookieStore.get operations are racing.
// |cookie| may be null or a dictionary including {name: "session-id"}.
console.log(cookie);
GOOD
await cookieStore.delete('session-id');
const response = await fetch('/auth');
const cookie = await cookieStore.get('session-id');
// |cookie| is guaranteed to be a dictionary including {name: "session-id"}.
console.log(cookie);
The ability of passing the return value of cookieStore.set()
to cookieStore.get()
may lead to code that reads like an atomic read-modify-write operation, when in reality it's not.
async function incrementCookie(name) {
const cookie = await cookieStore.get(name);
cookie.value = parseInt(cookie.value) + 1;
await cookieStore.set(cookie);
}
await cookieStore.set('cookie-name', 1);
await Promise.all([
incrementCookie('cookie-name'),
incrementCookie('cookie-name'),
]);
const cookie = await cookieStore.get('cookie-name');
console.log(cookie.value); // Value will likely be '2' not '3'.
The API will be able to fetch insecure cookies, but will only be able to modify secure cookies. This will mean that when modifying an insecure cookie with the API, the insecure cookie will automatically be changed to secure.
const cookie = await cookieStore.get('insecure-cookie');
cookie.value = 'new-value';
cookieStore.set(cookie); // 'cookie' will be modified into a secure cookie.
The APIs in this proposal signal errors by rejecting their promises. In most
situations, this outcome is better than the silent failures in the
document.cookie
setter, and than the empty string results in the corresponding
getter.
However, the APIs will also reject their promises due to implementation-specific limitations, such as overly long cookie names or values, expiration dates too far in the future, permissions denied by the user, or internal errors. These limitations are not consistent across browsers, so developers must have the foresight to handle errors they might not experience while building their sites.
RFC 6265bis explains HTTP cookies. Cookies were implemented independently in separate HTTP stacks without a comprehensive testing suite, leading to visible cross-browser incompatibilities. The RFC explains many of these incompatibilities and lays out a path for a new implementation to maximize its interoperability with the existing software landscape.
document.cookie
implements synchronous access to the list of cookies associated with the
document's URL. document.cookie
deviates from the cookies RFC.
The synchronous model is
non-trivial to implement in modern browsers.
navigator.cookieEnabled aims to predict whether setting a cookie will succeed or not.
cookie change events
is a concurrently developed API proposal that also addresses the synchronous
nature of document.cookie
.
inikulin/cookie-compat is a test suite that highlights differences between RFC 6265bis and the way current browsers handle cookies.
The chrome.cookies API (and the WebExtensions adaptation of the API) provides very similar functionality for trusted code added to browsers, but is not restricted by the same-origin policy.