Refreshing and Storing Access Tokens Securely
Where an MV3 extension should keep access and refresh tokens, how to refresh once under concurrency, handling rotation and revocation, and what encryption actually buys you.
Table of Contents
- Root cause: an evictable worker has no reliable memory
- Step 1. Lock down the session area
- Step 2. Refresh exactly once, however many callers ask
- Step 3. Persist the rotated pair atomically
- Step 4. Retry once on a 401 from the resource server
- Step 5. Understand what encryption does and does not buy
- Cross-browser variation
- Verification
- FAQ
- Related
The access token arrives with an expiry an hour away, and an hour later three parts of your extension try to use it at the same moment. A naive refresh sends three requests, two of which fail because the provider rotates refresh tokens and invalidated the one the other two are holding. The user is signed out for no reason they can see. This page is part of Identity & OAuth Authentication and covers storage placement, single-flight refresh, rotation, and the honest limits of encrypting a secret on a device the attacker already controls.
Root cause: an evictable worker has no reliable memory
In a web app a token can live in a module variable for the lifetime of the tab. In Manifest V3 the service worker is torn down after roughly thirty seconds of inactivity and rebuilt on the next event, so a module variable is a cache with an unpredictable lifetime. Anything the extension must still know after eviction has to be in a storage area, and the choice of area is the whole security decision.
The shape of the answer: the short-lived access token goes in chrome.storage.session, which lives in memory, is cleared when the browser closes, and defaults to an access level that content scripts cannot reach. The long-lived refresh token has to survive a browser restart, so it goes in chrome.storage.local — and that is a real trade-off rather than a solved problem, which the last step of this page addresses honestly.
Step 1. Lock down the session area
chrome.storage.session defaults to TRUSTED_CONTEXTS, but the default has changed across Chrome versions and a call to setAccessLevel costs nothing.
1// sw.js — top level, before any listener that might read a token
2chrome.storage.session.setAccessLevel?.({ accessLevel: "TRUSTED_CONTEXTS" })
3 .catch(() => { /* not implemented in this engine — the default is already trusted */ });
Execution context: Service worker, executed during script evaluation so it applies before the first event handler runs. The optional call operator matters: setAccessLevel does not exist in Firefox or Safari, and an unguarded call throws during worker startup, which takes down every listener registration below it. Chrome’s TRUSTED_CONTEXTS excludes content scripts, which is the property you want — a compromised page should not be able to read a bearer token out of the extension.
Step 2. Refresh exactly once, however many callers ask
The rotation problem is a concurrency problem. One in-flight promise, shared by every caller, removes it entirely.
1// sw-token.js
2const SKEW_MS = 60_000; // refresh a minute early — clocks disagree
3let inFlight = null;
4
5export async function getAccessToken() {
6 const { auth } = await chrome.storage.session.get("auth");
7 if (auth?.access && auth.expiresAt - Date.now() > SKEW_MS) return auth.access;
8
9 inFlight ??= refresh().finally(() => { inFlight = null; });
10 return inFlight;
11}
12
13async function refresh() {
14 const { refresh: token } = await chrome.storage.local.get("refresh");
15 if (!token) throw new Error("no-refresh-token");
16
17 const res = await fetch(TOKEN_URL, {
18 method: "POST",
19 headers: { "Content-Type": "application/x-www-form-urlencoded" },
20 body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: token, client_id: CLIENT_ID }),
21 });
22
23 if (res.status === 400 || res.status === 401) {
24 await chrome.storage.local.remove("refresh"); // the grant is gone for good
25 throw new Error("refresh-rejected");
26 }
27 if (!res.ok) throw new Error(`refresh-failed-${res.status}`);
28
29 const body = await res.json();
30 await persist(body);
31 return body.access_token;
32}
Execution context: Service worker. inFlight is intentionally in-memory only — after eviction there are no concurrent callers left to coordinate, so persisting it would add a lock nobody needs to release. The distinction between a 400/401 and any other status is the important branch: a rejected grant is permanent and must clear local state so the UI can prompt for sign-in, while a 503 is transient and must not sign the user out. All three engines behave identically here because this is plain fetch and storage.
Step 3. Persist the rotated pair atomically
Providers that rotate refresh tokens invalidate the old one the moment the new one is issued. Writing the access token and forgetting the new refresh token leaves the extension permanently unable to refresh again.
1// sw-token.js
2async function persist(body) {
3 const expiresAt = Date.now() + (body.expires_in ?? 3600) * 1000;
4
5 await chrome.storage.session.set({
6 auth: { access: body.access_token, expiresAt, scope: body.scope },
7 });
8
9 // Rotation: the response carries a new refresh token only when the provider rotates.
10 if (body.refresh_token) {
11 await chrome.storage.local.set({ refresh: body.refresh_token, refreshIssuedAt: Date.now() });
12 }
13}
Execution context: Service worker. The two set calls are separate storage transactions, so there is a window in which the new access token is stored and the new refresh token is not — if the worker is killed in that window, the next refresh uses the invalidated old token and fails. Writing the refresh token first would narrow the window in the other direction; the robust fix is to treat refresh-rejected as “prompt for sign-in” rather than trying to make two writes atomic, because chrome.storage offers no transaction across areas in any engine.
Step 4. Retry once on a 401 from the resource server
Expiry maths is a prediction. The authoritative signal that a token is dead is the resource server rejecting it.
1// api.js — every call to the backend goes through here
2export async function apiFetch(path, init = {}) {
3 const send = async (token) => fetch(`https://api.example.com${path}`, {
4 ...init,
5 headers: { ...init.headers, Authorization: `Bearer ${token}` },
6 });
7
8 let res = await send(await getAccessToken());
9
10 if (res.status === 401) {
11 await chrome.storage.session.remove("auth"); // force the gate to refresh
12 res = await send(await getAccessToken()); // exactly one retry
13 }
14
15 return res;
16}
Execution context: Service worker, or any extension page that imports the module — never a content script, which must not see the token at all and should route requests through the worker via message passing. Retrying exactly once is deliberate: a loop here turns a revoked grant into an infinite request storm against the provider, which is the behaviour that gets client IDs rate-limited.
Step 5. Understand what encryption does and does not buy
Encrypting the refresh token before writing it to chrome.storage.local is a reasonable defence in depth, and it is important to be clear about the threat it addresses. The key has to be derivable by the extension without user input, which means it is on the same disk as the ciphertext. An attacker with code execution in your extension’s context can decrypt it exactly as your code does.
What it does defeat: casual inspection of the profile directory, a backup or sync of the profile folder to another machine, and other extensions or tooling that scrape LevelDB files off disk. That is a real class of exposure, and encrypting sensitive data in chrome storage shows the Web Crypto pattern. What it does not defeat is an attacker who already runs code in the extension. If the token protects something valuable, the stronger answer is a short refresh-token lifetime and a provider that supports sender-constrained tokens, not a better cipher on the client.
Cross-browser variation
- Chrome/Edge:
storage.sessionis capped at 10 MB andsetAccessLevelis available. Session data is cleared when the last browser window closes, not when the worker is evicted. - Firefox:
browser.storage.sessionis supported and content scripts cannot read it, butsetAccessLevelis not implemented — the guarded optional call above is what keeps startup working. - Safari:
browser.storage.sessionis supported from Safari 16.4. Safari reclaims extension memory more aggressively, so expect the session area to be repopulated by a refresh more often than on the other engines; the single-flight gate absorbs this without extra code. - All engines: never place either token in
storage.sync. It replicates to every device on the profile, is subject to a 100 KB per-item and 8 KB per-write quota, and turns one compromised device into all of them.
Verification
- In the worker console, set the stored expiry into the past:
await chrome.storage.session.set({ auth: { access: "x", expiresAt: 0 } }). The nextgetAccessToken()should perform exactly one network request — confirm in the Network panel of the worker’s own DevTools window. - Fire two calls at once with
await Promise.all([getAccessToken(), getAccessToken()])and confirm the Network panel still shows one request. - Revoke the grant from the provider’s dashboard, then call
apiFetch. It should surface a sign-in prompt rather than retrying, andchrome.storage.local.get("refresh")should come back empty. - Close and reopen the browser.
storage.sessionshould be empty and the first API call should transparently refresh.
FAQ
Why not just keep the token in a module variable?
Because the worker is evicted between events. The variable is a useful first-level cache — getAccessToken reads storage on every call, which is a few milliseconds — but it cannot be the only copy.
How early should I refresh before expiry?
A minute of skew is enough for clock drift and network latency. Refreshing much earlier wastes grants on providers that rotate; refreshing on expiry exactly guarantees occasional 401s.
The provider does not issue refresh tokens. Now what?
Then the access token lifetime is the session lifetime, and you re-run the interactive flow when it ends. Store it in storage.session and prompt on expiry; there is nothing to persist to disk, which is a simpler security position.
Can a content script get the token if it needs to call the API?
It should not have it. Send the request from the worker and pass the result back, so a compromised page can at most ask for data it is already allowed to see.
Related
- Implementing OAuth 2.0 with launchWebAuthFlow — how the first token got here.
- Chrome storage session vs local — the two areas this page relies on, in detail.
- Encrypting sensitive data in chrome storage — the Web Crypto pattern and its threat model.
- Identity & OAuth Authentication — the guide this page belongs to.