Skip to content

Fix "Access to storage is not allowed from this context" in a Chrome extension

6 min readModerok team

Why chrome.storage.session throws "Access to storage is not allowed from this context" in a content script, the setAccessLevel fix, and what it misses.

If a content script throws Uncaught (in promise) Error: Access to storage is not allowed from this context., you are reading or writing chrome.storage.session from a context Chrome treats as untrusted. Session storage is closed to content scripts by default. Open it from a trusted context, meaning your service worker or an extension page:

// background.js (the MV3 service worker)
chrome.storage.session.setAccessLevel({
  accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS",
});

That is the fix. The rest of this post covers why this one storage area behaves differently, where that call belongs and how long the grant lasts, what still breaks afterwards, and why Firefox has no equivalent.

Why "access to storage is not allowed from this context" is nearly always session

Three of the four storage areas are open to content scripts: chrome.storage.local, chrome.storage.sync and chrome.storage.managed all work there with nothing but "permissions": ["storage"] in the manifest. The fourth does not. MDN's storage.session page states the default: "By default, it's not exposed to content scripts, but this behavior can be changed through storage.session.setAccessLevel()." The setAccessLevel() page says why: "Unlike other storage areas, by default, storage.session is only available to privileged (trusted) extension contexts by default."

A content script is not one of those. It runs in an isolated world inside the page's renderer process, and Chromium's check is about that process rather than the world: the call is allowed only from a privileged extension process. A content script is not one, so the call is rejected instead of returning empty data. The chrome.storage.session object still exists there, which is what makes the failure confusing: the property is present, get() is callable, and the promise rejects at runtime.

The check itself is not session-specific: it runs for whichever area you called, and the stored access level for that area decides the outcome. If you have never called setAccessLevel(), session is the only area that can produce the error, but the same string can come from chrome.storage.local on Chrome 140 and later (see below).

Chrome's chrome.storage reference says the same thing: "By default, it's not exposed to content scripts, but this behavior can be changed by calling chrome.storage.session.setAccessLevel()." The two values it accepts are described there as "TRUSTED_CONTEXTS Specifies contexts originating from the extension itself" and "TRUSTED_AND_UNTRUSTED_CONTEXTS Specifies contexts originating from outside the extension."

Note the asymmetry: a content script cannot fix its own access. setAccessLevel() is itself a privileged call, so running it there fails with the same error you are trying to remove. The grant has to come from the service worker or an extension page.

Where the setAccessLevel call belongs

Both the top level of the service worker and a chrome.runtime.onInstalled listener work, and the choice comes down to how long the grant lasts, which neither Chrome's reference nor MDN says:

// background.js, top level
if (chrome.storage.session?.setAccessLevel) {
  chrome.storage.session
    .setAccessLevel({ accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS" })
    .catch((err) => console.warn("setAccessLevel failed:", err));
}

chrome.runtime.onMessage.addListener(() => {
  /* your real listeners, registered synchronously */
});

In current Chromium the grant is durable. It is stored as an extension preference, not in the worker's memory: storage_utils.cc declares kPrefSessionStorageAccessLevel with PrefScope::kExtensionSpecific, SetAccessLevelForArea() writes it through ExtensionPrefs, and GetAccessLevelForArea() reads it back, falling through to a per-area default when nothing is stored: trusted contexts for session, trusted and untrusted for local, sync and managed (source). One call outlives the worker and a browser restart, so a single onInstalled call really is enough today, and it is the smaller amount of work. What it is not is documented, which is the argument for the top-level version: it repeats a cheap call on every cold start and does not rest on an implementation detail no reference page promises to keep.

The optional chaining is on chrome.storage.session itself, because that whole area is missing in Firefox before 115, Chrome before 102 and Safari before 16.4 (per MDN's compatibility table), and a synchronous TypeError during top-level evaluation fails the worker's registration outright. The .catch() is there because setAccessLevel() returns a promise.

What still breaks after the fix

The data is still gone at the wrong moments. Chrome's reference says session storage "holds data in memory while an extension is loaded" and that "the storage is cleared if the extension is disabled, reloaded, updated, and when the browser restarts." Granting content scripts access changes none of that. What session storage does survive is service worker termination, which is the point of it: the worker can be shut down after its idle timeout and read the same values back on the next wake, as described in why your MV3 service worker keeps stopping. Treat it as a cache that lives exactly as long as the current load of your extension, never as the record.

The first run after install is still a race. The check happens in the browser process against the stored pref, so a content script never waits on the worker to boot and a sleeping worker is not a problem. The window that remains is the one before the grant has ever been made: a content script that races the very first evaluation of your worker can still see the rejection once. Catch it rather than letting a page load throw.

The grant is extension-wide, not per-origin. There is one access level for the session area, so every content script on every site your manifest matches can now read everything in there, not just the key you had in mind. Auth tokens and anything else you would not want reachable from a page you do not control should stay in the service worker.

The quota is separate from local. The same reference gives session a limit of "10 MB (1 MB in Chrome 111 and earlier)" and exceeding it rejects the write. If you are moving data between areas to dodge a limit, the exact numbers for each area are in chrome.storage.local QUOTA_BYTES quota exceeded.

Firefox, Safari, and Chrome version differences

Firefox supports storage.session from Firefox 115 but does not implement setAccessLevel() at all: MDN's compatibility table for StorageArea.setAccessLevel() records no supported Firefox version and links the tracking bug 1724754. Session storage there is restricted to trusted contexts too, so a Firefox content script has no supported way to reach it, and the guard above is what keeps a shared background script from throwing on the missing method.

Safari has the method from Safari 17.1, and per the same table only for the session area. Chrome's coverage is wider: MDN records setAccessLevel() on every storage area from Chrome 140, so you can now go the other direction and close chrome.storage.local to content scripts with TRUSTED_CONTEXTS. That is real hardening, and also a way to break your own content scripts in one line, so check the version floor you support first.

Consider not sharing the area at all

Before granting access, ask what the content script actually needs. If it needs one value, sending a message is usually less surface area than opening a storage area:

// content.js (a classic script, so no top-level await)
chrome.runtime
  .sendMessage({ type: "get-theme" })
  .then((res) => {
    if (res) applyTheme(res.theme);
  })
  .catch((err) => console.warn("no answer from the worker:", err));

The service worker answers from session storage and returns only that value, so the content script never touches the area and the access level stays at its default. The two handlers cover different failures. If no listener answers, the promise rejects, which in Chrome is the Could not establish connection. Receiving end does not exist. message: that is the .catch(). If a listener runs but returns nothing, MDN's runtime.sendMessage says the promise "will be fulfilled with no arguments", so res is undefined and reading res.theme would throw: that is the if. Messaging has its own failure modes, including the response path breaking when the worker is torn down mid-request, covered in why "the message port closed before a response was received".

Analytics is one thing that does not belong in this discussion at all: it can live in the service worker, where the trusted context and the durable storage already are. The Moderok SDK keeps its config, its anonymous profile id and its pending events in chrome.storage.local, and mirrors the profile id to chrome.storage.sync so it can be recovered after a profile wipe. Both areas are covered by the storage permission, so there is no access level to change and no host_permissions to add. The manifest and permissions guide has the manifest entries it does need.