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.
Table of Contents
- Trusted and untrusted contexts
- Step-by-step: choose and set access levels
- 1. Decide what content scripts actually need
- 2. Restrict local storage to trusted contexts when it holds secrets
- 3. Expose session storage only for non-sensitive data
- 4. Read in content scripts with errors handled
- 5. Listen for changes only where you can read
- 6. Remember it is a defence, not a vault
- 7. Test both access paths
- Common mistakes
- Cross-browser variation
- Verification
- FAQ
- Related
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.
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.
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.
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
setAccessLevelfrom a content script. Untrusted contexts cannot change it.
Cross-browser variation
- Chrome / Edge:
setAccessLevelonlocal,syncandsessionfrom Chrome 102; session defaults to trusted-only. - Firefox:
storage.sessionis available from 115 and is not exposed to content scripts;setAccessLevelsupport has lagged Chrome — feature-detect it and fall back to messaging. - Safari:
storage.sessionfrom 16.4; access-level control is limited. Assume content scripts can readlocalandsync, and keep secrets out of both.
Verification
- Write a value to
chrome.storage.sessionfrom the worker and try to read it from a content script: expect a rejection. - Call
setAccessLevel({ accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS" })onsessionand repeat: the read succeeds. - Restrict
localand confirm content script reads fail and itsonChangedlistener receives nothing. - 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.
Related
- Chrome storage session vs local — choosing the area first.
- Encrypting sensitive data in chrome.storage — a further layer for persisted secrets.
- Refreshing and storing access tokens securely — where tokens belong.
- chrome.storage API and sync — the parent topic.