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.

Published October 2, 2026 Updated October 2, 2026 8 min read
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.

Common reasons cookies.get returns nullFive mismatches between the query URL and the stored cookie that cause chrome.cookies.get to resolve null, with the check that reveals each.MismatchStored cookieQuery URLFixPathpath=/apphttps://x.com/Query a URL under /appHost-onlyhost app.x.comhttps://x.com/Use the exact hostSecuresecurehttp://…Use httpsPartitionedPartitionedno partitionKeyPass partitionKeyStoreincognito storedefault storeIdPass storeId
Every one of these is the browser applying normal cookie rules to the URL you passed.

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.

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.

What cookies.set validates before it stores anythingThe write passes a host-permission check, a domain-covers-URL check, the secure and SameSite rules, and the size limit before being committed to the store.Host permissionurl origin grantedDomain covers urlor host-onlysecure + httpsand SameSite=None rulethen the store applies its own limitsSize ≤ 4096 bytesname + valuePer-domain capoldest evictedCommittedonChanged fires
A failure at any step resolves the promise with null — check the return value every time.

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 rejected set resolves null and sets lastError. Partitioned cookies need partitionKey on every call that should see them.
  • Firefox: browser.cookies with native promises; a rejected set rejects the promise with a message, which is easier to debug. With first-party isolation enabled every call must pass firstPartyDomain, or it throws. Container stores appear as separate storeIds.
  • Safari: supports get, getAll, set, remove and getAllCookieStores, but sameSite and hostOnly are 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

  1. Open the service worker DevTools from chrome://extensions and 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.

  1. Call your setPreference helper and confirm the cookie appears in the page’s DevTools under Application → Cookies with the attributes you intended.
  2. 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”.
Typical causes of a null cookies.get resultIllustrative share of null chrome.cookies.get results by root cause: path mismatch, host-only mismatch, missing host permission, partition key and other.Path mismatch38 %Host-only vs domain27 %Host permission withdrawn19 %Partitioned cookie9 %Other7 %
In a typical support queue, most missing-cookie reports trace back to the query URL, not the cookie.

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.

Other Core APIs & Cross-Browser Data Management Resources