Reading and Setting Cookies with chrome.cookies
Why chrome.cookies.get returns null and set silently fails: build the right URL, choose host-only or domain cookies, and set secure, SameSite and expiry correctly in MV3.
Table of Contents
The symptom is always the same: you can see the cookie in DevTools under Application → Cookies, but chrome.cookies.get resolves to null, or chrome.cookies.set resolves without error and the cookie never appears. Neither call explains itself. Both are doing exactly what the cookie specification says, and the fix is to build the query the way the browser’s own cookie matching works. This guide belongs to cookies and webRequest observation.
Why the API answers “nothing”
chrome.cookies does not look cookies up by name in a table. It asks the network stack “which cookie named X would be sent with a request to this URL?” — and that question runs the full matching algorithm: the cookie’s domain must match the URL’s host (exactly for host-only cookies, as a suffix for domain cookies), the cookie’s path must be a prefix of the URL’s path, a secure cookie needs an https URL, and partitioned cookies need a matching partition key. Fail any filter and the answer is null. set runs the same rules in reverse: a write the browser would reject from a Set-Cookie header — a secure cookie on an http URL, SameSite=None without secure, a domain that does not cover the URL’s host — is dropped, and the promise resolves to null with chrome.runtime.lastError set, which a promise-based caller never reads.
Step-by-step: reliable reads and writes
1. Declare the permission and the hosts together
The cookies permission alone grants nothing useful — every call is filtered by host permissions.
1{
2 "permissions": ["cookies"],
3 "host_permissions": ["https://*.example.com/*"] // covers example.com and every subdomain
4}
Execution context: the manifest, read at install. In Chrome the user can later restrict site access, which removes hosts at runtime; in Firefox MV3 host permissions start ungranted. Either way, a missing host makes every call for that domain resolve empty rather than throw.
2. Read with the URL the cookie is actually sent to
When you do not know the exact path, read with getAll and a domain filter, then choose.
1export async function findCookie(name, domain) {
2 // getAll with `domain` matches the domain and every subdomain
3 const all = await chrome.cookies.getAll({ name, domain });
4 if (all.length === 0) return null;
5 // Prefer the most specific: host-only over domain cookies, longest path first
6 all.sort((a, b) => Number(a.hostOnly === false) - Number(b.hostOnly === false)
7 || b.path.length - a.path.length);
8 return all[0];
9}
10
11const session = await findCookie("session_id", "example.com");
12console.log(session?.domain, session?.path, session?.hostOnly);
Execution context: the service worker or any extension page holding the cookies permission — not a content script. getAll ignores path and secure, which makes it the right diagnostic tool; get applies them, which makes it the right production lookup once you know the URL. Firefox supports the same filters and adds firstPartyDomain, which must be passed (even as null) when first-party isolation is on.
3. Decide between host-only and domain cookies before you write
Omitting domain in set creates a host-only cookie bound to the exact host of url. Passing domain creates a domain cookie sent to that domain and all its subdomains — the browser stores it with a leading dot.
1// Host-only: sent to app.example.com and nowhere else
2await chrome.cookies.set({ url: "https://app.example.com/", name: "a", value: "1" });
3
4// Domain cookie: sent to example.com, app.example.com, api.example.com …
5await chrome.cookies.set({
6 url: "https://app.example.com/",
7 domain: "example.com",
8 name: "b",
9 value: "1",
10});
Execution context: the service worker. The url must still be covered by both your host permissions and the domain; domain: "example.com" with url: "https://other.com/" is rejected. Choosing wrongly here is the most common cause of a cookie that the site “cannot see” — a host-only cookie written for www is invisible to api.
4. Set the security attributes the browser now requires
1export async function setPreference(name, value, days = 180) {
2 const cookie = await chrome.cookies.set({
3 url: "https://app.example.com/",
4 name,
5 value: encodeURIComponent(value),
6 path: "/",
7 secure: true,
8 httpOnly: false, // the page's own JS should be able to read it
9 sameSite: "lax",
10 expirationDate: Math.floor(Date.now() / 1000) + days * 86400,
11 });
12 if (!cookie) throw new Error(`cookie ${name} rejected: ${chrome.runtime.lastError?.message}`);
13 return cookie;
14}
Execution context: the service worker. expirationDate is seconds since the epoch, not milliseconds — passing Date.now() sets an expiry thousands of years away in some browsers and an immediate expiry in others. sameSite: "no_restriction" requires secure: true; without it Chrome drops the write. Firefox and Safari accept the same keys, and Safari rejects sameSite values it does not recognise rather than ignoring them.
5. Delete with the same coordinates you wrote with
remove takes a URL and a name, and it removes only the cookie that URL would match — so removing a domain cookie needs a URL on that domain, and removing a path-scoped cookie needs a URL under that path.
1export async function removeEverywhere(name, domain) {
2 const all = await chrome.cookies.getAll({ name, domain });
3 await Promise.all(all.map((c) => {
4 const host = c.domain.replace(/^\./, "");
5 const url = `${c.secure ? "https" : "http"}://${host}${c.path}`;
6 return chrome.cookies.remove({ url, name: c.name, storeId: c.storeId });
7 }));
8}
Execution context: the service worker. Rebuilding the URL from each cookie’s own fields is the only reliable way to remove every variant — the same name can exist as a host-only cookie on www and a domain cookie on the apex. Firefox’s container stores need the storeId passed back exactly as returned.
Cross-browser variation
- Chrome / Edge: promises from
chrome.cookies.*since Chrome 88. A rejectedsetresolvesnulland setslastError. Partitioned cookies needpartitionKeyon every call that should see them. - Firefox:
browser.cookieswith native promises; a rejectedsetrejects the promise with a message, which is easier to debug. With first-party isolation enabled every call must passfirstPartyDomain, or it throws. Container stores appear as separatestoreIds. - Safari: supports
get,getAll,set,removeandgetAllCookieStores, butsameSiteandhostOnlyare sometimes omitted from returned objects, and Intelligent Tracking Prevention may purge cookies for domains the user has not interacted with — a cookie your extension set can vanish after seven days.
Verification
- Open the service worker DevTools from
chrome://extensionsand run the diagnostic read:
1(await chrome.cookies.getAll({ domain: "example.com" }))
2 .map(({ name, domain, path, hostOnly, secure, sameSite }) =>
3 ({ name, domain, path, hostOnly, secure, sameSite }));
Execution context: the service worker console. Compare the domain, path and hostOnly fields with the URL your production code passes to get — the mismatch is almost always visible in this table.
- Call your
setPreferencehelper and confirm the cookie appears in the page’s DevTools under Application → Cookies with the attributes you intended. - Revoke the host permission (Chrome: right-click the action → “This can read and change site data” → “When you click the extension”) and confirm your code reports “no access” instead of “signed out”.
FAQ
Can a content script read cookies through chrome.cookies?
No. The API is not exposed to content scripts. A content script can read non-httpOnly cookies for its own page through document.cookie, but for anything else it must message the service worker, which makes the call with the extension’s permissions.
Why does getAll return cookies that get cannot find?
getAll filters only on the fields you pass — name, domain, path, secure, session, store — while get applies the full URL-matching rules. A cookie visible to getAll({ domain }) but not to get({ url }) is telling you the URL’s path or host does not match it.
Can I read httpOnly cookies?
Yes. httpOnly hides a cookie from page JavaScript, not from extensions with the right host permission. That is exactly why reviewers scrutinise the cookies permission paired with broad hosts — session tokens are readable.
Related
- Reacting to cookie changes — follow a value after you have found it.
- Partitioned cookies and CHIPS in extensions — the partition key filter in depth.
- Cookie stores in incognito and Firefox containers — choosing the right
storeId. - Cookies and webRequest observation — the parent topic.