Skip to content

Fix chrome.cookies.get returning null in a Chrome extension

6 min readModerok team

Why chrome.cookies.get returns null in a Manifest V3 extension: the host permission rule, the url argument, partitioned cookies, and Firefox differences.

If chrome.cookies.get() returns null for a cookie you can see sitting in DevTools, the usual cause is that the cookies permission on its own does nothing. The API also needs a host permission matching the URL you pass:

{
  "manifest_version": 3,
  "permissions": ["cookies"],
  "host_permissions": ["https://*.example.com/*"]
}

Reload the extension from chrome://extensions afterwards. Chrome's cookies API reference states the rule against the url argument: "If host permissions for this URL are not specified in the manifest file, the API call will fail."

Two permissions, not one

Most Chrome APIs are unlocked by a single entry in permissions. Cookies is not one of them. Chrome's Declare permissions page sets up the general case: "Some Chrome APIs require host permissions in addition to their own API permissions, which are documented on each reference page." The cookies reference is one of those pages, and it spells out the pair: declare the cookies permission along with host permissions for any hosts whose cookies you want to access.

A manifest with "permissions": ["cookies"] and no host_permissions is not half configured. It is an extension that can call the cookies API and read nothing. Two consequences:

  • The match pattern is the access boundary. https://*.example.com/* does not cover an http:// URL, and does not cover example.org at all.
  • Broad host permissions change your install prompt. Asking for <all_urls> to read one cookie is the fastest way to put the "read and change all your data on the websites you visit" warning on your listing, which we covered in why that permission warning appears and how to narrow it. Request the narrowest pattern that covers the cookie you need.

Stop guessing: read the failure

A null result and a rejected call look identical if you do not check. In callback style, chrome.runtime.lastError carries the reason; with the promise form, the rejection does:

chrome.cookies.get({ url: "https://app.example.com/", name: "session" }, (cookie) => {
  if (chrome.runtime.lastError) {
    console.error("cookies.get failed:", chrome.runtime.lastError.message);
    return;
  }
  console.log(cookie); // null here means "no matching cookie", not "no permission"
});

That distinction is the whole diagnosis. A logged permission error means the manifest is wrong. A clean null means Chrome looked and found nothing. Callbacks that never check lastError swallow the first case, which is how a permissions bug spends an afternoon disguised as a missing cookie.

The url argument is matched, not searched

cookies.get() is not a query language: one URL, one name, at most one cookie. Three documented details explain most of the remaining nulls:

  • The query string is discarded, the path is not. The reference says the argument "may be a full URL, in which case any data following the URL path (e.g. the query string) is simply ignored." The path portion still participates in matching, so https://app.example.com/ and https://app.example.com/dashboard are not interchangeable for a cookie scoped to /dashboard.
  • Ties are broken by path length. Per the same page: "If more than one cookie of the same name exists for the given URL, the one with the longest path will be returned. For cookies with the same path length, the cookie with the earliest creation time will be returned." A Path=/ and a Path=/dashboard cookie of the same name both match a request for .../dashboard, and you always get the /dashboard one. To reach the shallower cookie, ask with a URL the deeper one does not match.
  • httpOnly is not the problem. The returned Cookie object has an httpOnly field, so cookies invisible to document.cookie are still visible to this API once permissions are right.

When you do not know the exact scope, switch to chrome.cookies.getAll({ domain: "example.com" }) and log what comes back: the fastest way to see whether the cookie exists at all, and under what path.

Why chrome.cookies.get still returns null after the permissions are right

Partitioned cookies. Chrome's cookies reference notes that in Chrome 119 the API gained partitioning support, that by default all API methods operate on unpartitioned cookies, and that partitionKey is "the partition key for reading or modifying cookies with the Partitioned attribute." A cookie set with Partitioned is therefore invisible to a plain get(). Name the partition:

const cookie = await chrome.cookies.get({
  url: "https://widget.example.com/",
  name: "session",
  partitionKey: { topLevelSite: "https://host-page.example" },
});

The wrong cookie store. The storeId field is documented as "the ID of the cookie store in which to look for the cookie," and by default "the current execution context's cookie store will be used." An incognito window is a different store, so a service worker running in the normal context will not see incognito cookies. That is not only a code fix: Chrome's docs note that "if your extension needs to run on file:// URLs or operate in incognito mode, users must give the extension access on its details page." Once that access is granted, enumerate the stores with chrome.cookies.getAllCookieStores() and pass the id you want.

Site access the user narrowed. A declared host permission is a request, not a guarantee for the life of the install. Chrome's guide to user controls for host permissions, written in the Manifest V2 era but describing the site-access UI users still see, says they "can choose to allow your extension to run on click, on a specific set of sites, or on all requested sites." That is why the chrome.permissions API offers contains():

const ok = await chrome.permissions.contains({ origins: ["https://*.example.com/*"] });

Firefox differences

Same two permissions, but two extra ways to get null or a rejection.

First, first-party isolation. MDN's cookies API page is direct: get(), getAll(), set() and remove() all accept a firstPartyDomain option, and "when first-party isolation is on, you must provide this option or the API call fails and returns a rejected promise." Code tested only against Chrome never passes it.

Do not hardcode a domain to satisfy it. MDN also says that with isolation off the parameter is optional and defaults to an empty string, and that cookies set by websites carry an empty string there. Isolation is off by default, so a hardcoded non-empty value matches nothing on an ordinary profile, and an empty string matches nothing on an isolated one.

Second, container tabs. MDN's contextual identities guide explains that each contextual identity gets a cookie store that is not shared with other tabs, identified by a cookieStoreId. A cookie set in a container is not in the default store.

getAll() handles both, as the one method that accepts null for firstPartyDomain to mean any value:

const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
const [cookie] = await browser.cookies.getAll({
  url: tab.url,
  name: "session",
  storeId: tab.cookieStoreId, // the container's store, not the default one
  firstPartyDomain: null,     // matches isolated and non-isolated cookies alike
});

The order to check things in

  1. Is "cookies" in permissions and a matching pattern in host_permissions? Reload after any manifest edit.
  2. Is chrome.runtime.lastError set? If yes, it is permissions or arguments, not a missing cookie.
  3. Does getAll({ domain }) return the cookie? If it does, the url you passed or the cookie's Path is the mismatch.
  4. If it does not, does the cookie carry Partitioned? Pass partitionKey.
  5. Incognito or a Firefox container? Pass storeId, and grant incognito access first.
  6. On Firefox, use getAll() with firstPartyDomain: null.

If you reached for cookies to count users, you do not need them

Some extensions pull in the cookies permission for one reason: identifying returning users for analytics. That is a bad trade. You expand your store listing's disclosure, inherit every partitioning and container edge case above, and get an identifier that dies the moment the user clears cookies.

Extension analytics does not need cookies. A random profile id in chrome.storage does the job with no host permissions at all, which is how the Moderok SDK works: the only permission it asks for is storage, documented on the manifest and permissions page. If cookies are load bearing for a real feature, work the checklist above. If they are only there to count people, drop the permission and use storage. Our post on which permissions an analytics SDK actually needs covers the rest of that decision.