Setting Storage Access Level for Content Scripts

Control which contexts can read chrome.storage with setAccessLevel: why storage.session is hidden from content scripts, exposing it safely, restricting local storage to trusted contexts, and Firefox and Safari behaviour.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

A content script calls chrome.storage.session.get("token") and gets an error — or an empty object — even though the service worker wrote the value a second ago. Meanwhile, chrome.storage.local is readable from every content script on every page, including the access token someone put there “temporarily”. Both behaviours come from one setting: each storage area has an access level that decides whether untrusted contexts — content scripts — can use it. The defaults differ by area, and setAccessLevel changes them. This guide explains when to change them and when not to. It belongs to chrome.storage API and sync.

Trusted and untrusted contexts

Chrome divides extension contexts into two groups. Trusted contexts — the service worker, popup, options page, side panel, offscreen documents and other extension pages — run on the extension’s origin, in processes the browser considers the extension’s own. Untrusted contexts are content scripts: they run inside web page renderers, alongside code you do not control, and a compromised renderer could misuse anything a content script can reach. Each storage area has an access level of either TRUSTED_CONTEXTS or TRUSTED_AND_UNTRUSTED_CONTEXTS. chrome.storage.session defaults to trusted-only because it is designed for sensitive, short-lived data such as tokens. local and sync default to both, for backward compatibility. setAccessLevel lets you change any of them.

Default access by storage areaWhether trusted contexts and content scripts can access chrome.storage.local, sync, session and managed by default, and whether the default can be changed.AreaExtension pages + workerContent scriptssetAccessLevellocalYesYes (default)Can restrictsyncYesYes (default)Can restrictsessionYesNo (default)Can exposemanagedReadReadNot applicable
session is hidden from content scripts by default; local and sync are not.

Step-by-step: choose and set access levels

1. Decide what content scripts actually need

1Content script needs              Area           Exposure
2-------------------------------------------------------------
3UI preferences (theme, size)      sync           fine to expose
4Per-site enable/disable list      local          fine to expose
5Feature flags                     local          fine to expose
6Auth tokens, API keys             session        keep trusted-only
7Cached user profile (PII)         session/local  keep trusted-only

Execution context: a design note. The question for each key is: if a malicious page’s renderer could read this, what is the harm? Preferences and flags are harmless. Tokens and personal data are not. Content scripts that need something sensitive should ask the worker for exactly the derived value they need — “is the user signed in?” rather than “give me the token”.

2. Restrict local storage to trusted contexts when it holds secrets

1// sw.js — top level, runs on every worker start
2chrome.storage.local.setAccessLevel({ accessLevel: "TRUSTED_CONTEXTS" });

Execution context: the service worker. The access level is not persisted across browser restarts in every version, so set it at the top level of the worker, where it runs on every start, before anything else could read. After this call, content scripts get an error from chrome.storage.local — so only do it if no content script needs local storage, or move what they need to sync or to messages. Calling it from a content script fails; only trusted contexts may change access levels.

A content script asks for a derived value instead of the secretThe content script cannot read the token from session storage; it asks the worker whether the user is signed in; the worker reads the token from trusted session storage and replies with a boolean.Content scriptService workerstorage.sessionsession.get('token') → denied{type:'auth:status'}get('token')token{signedIn: true}
The secret never enters the page's renderer process.

3. Expose session storage only for non-sensitive data

1// sw.js — top level
2chrome.storage.session.setAccessLevel({ accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS" });

Execution context: the service worker. Exposing session makes sense when you use it as a fast, memory-only cache for per-tab UI state that content scripts read on every page load — counts, the current highlight term, a temporary “snooze” flag — and nothing in it is sensitive. Once exposed, the whole area is readable by content scripts; there is no per-key access control. If you need both sensitive and shared session data, keep the sensitive data in worker memory or behind messages instead.

4. Read in content scripts with errors handled

 1// content.js
 2async function readPrefs() {
 3  try {
 4    const { prefs = {} } = await chrome.storage.sync.get("prefs");
 5    return prefs;
 6  } catch (err) {
 7    // Access denied (area restricted) or context invalidated after an update
 8    return {};
 9  }
10}

Execution context: a content script. A restricted area rejects with an “Access to storage is not allowed from this context” error. Context invalidation after an extension update produces a different error with the same handling: fall back to defaults and stop. Keep content scripts resilient to both, because users keep pages open across updates.

5. Listen for changes only where you can read

1// content.js
2chrome.storage.onChanged.addListener((changes, area) => {
3  if (area === "sync" && changes.prefs) applyPrefs(changes.prefs.newValue);
4});

Execution context: a content script. Change events for an area are delivered only to contexts that can access that area. If you restrict local, content scripts stop receiving its onChanged events too — a quiet behaviour change worth testing when you tighten access.

Which access level for this area?Decision tree: areas holding secrets stay or become trusted-only; areas holding only preferences and flags may be readable by content scripts; mixed areas should be split.What does the area hold?secrets / PIITRUSTED_CONTEXTSworker and pages onlyContent scripts askvia messagesprefs / flagsTRUSTED_AND_UNTRUSTEDcontent scripts readonChanged worksin content scriptsbothSplit itsession vs syncDifferent levelsper area
Access levels are per area — split data by sensitivity, not by convenience.

6. Remember it is a defence, not a vault

Access levels stop content scripts from reading storage directly. They do not stop a compromised extension page, a malicious dependency in your own bundle, or a user with DevTools. Treat the restriction as one layer: keep tokens short-lived, store only what you need, and validate every message a content script sends before acting on it, as described in treating content script messages as untrusted.

7. Test both access paths

1// Playwright: evaluate in the page's content-script world is not directly possible;
2// instead expose a test-only message the content script answers with its read result.
3const result = await page.evaluate(() => window.postMessage({ test: "read-session" }, "*"));

Execution context: an end-to-end test harness with a development-only probe in the content script. Assert that sensitive reads fail from the content script and succeed from an extension page. A regression that loosens access — someone adding TRUSTED_AND_UNTRUSTED_CONTEXTS to “fix” a bug — should fail CI.

Common mistakes

  • Setting the access level once in onInstalled. It may not persist across restarts; set it at the top level of the worker.
  • Exposing session storage that holds tokens. One line turns a protected area into one readable by every page renderer.
  • Restricting local without checking content scripts. Their reads start failing and their change listeners go silent.
  • Assuming per-key control. The level applies to the whole area.
  • Calling setAccessLevel from a content script. Untrusted contexts cannot change it.

Cross-browser variation

  • Chrome / Edge: setAccessLevel on local, sync and session from Chrome 102; session defaults to trusted-only.
  • Firefox: storage.session is available from 115 and is not exposed to content scripts; setAccessLevel support has lagged Chrome — feature-detect it and fall back to messaging.
  • Safari: storage.session from 16.4; access-level control is limited. Assume content scripts can read local and sync, and keep secrets out of both.

Verification

  1. Write a value to chrome.storage.session from the worker and try to read it from a content script: expect a rejection.
  2. Call setAccessLevel({ accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS" }) on session and repeat: the read succeeds.
  3. Restrict local and confirm content script reads fail and its onChanged listener receives nothing.
  4. Restart the browser and confirm the levels are re-applied by the worker’s top-level code.

FAQ

Does this affect extension pages embedded in iframes on websites?

Extension pages are trusted contexts even when framed, so they keep access. Validate their postMessage inputs regardless.

Can I check the current access level?

There is no getter. Treat the worker’s top-level call as the source of truth.

Is chrome.storage.managed affected?

No. Managed storage is read-only policy data and readable from content scripts.

Should I move preferences to sync just so content scripts can read them?

Only if they should also follow the user across devices. Otherwise keep them in local with the default access, or restrict local and send content scripts a snapshot of the preferences they need when they start. The snapshot approach also means a content script never sees keys it has no use for.

Other Core APIs & Cross-Browser Data Management Resources