Authenticating Against Your Own Backend with JWTs

Authenticate an MV3 extension to your API: exchange an OAuth code for short-lived JWT access tokens and rotating refresh tokens, store them safely, attach them from the service worker, and verify on the server.

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

The extension talks to your API, and the API needs to know who is calling. Cookies from your website are unreliable from an extension — third-party cookie rules, SameSite, and per-store extension ids all get in the way — and an API key baked into the package identifies the extension, not the user, and leaks the moment someone unzips it. The robust design is the same one mobile apps use: the user signs in through OAuth, your backend issues a short-lived signed access token (a JWT) and a longer-lived refresh token, and the service worker attaches the access token to every request and refreshes it when it expires. This guide builds that flow for MV3. It belongs to identity and OAuth authentication.

Why tokens fit the extension model

An extension is a public client: everything in its package is readable by anyone, so it cannot hold a client secret, and its requests come from contexts — a service worker, a popup — that do not share your website’s cookie jar in any dependable way. Bearer tokens solve both. The user proves who they are to an identity provider (yours or a third party’s) through launchWebAuthFlow; your backend exchanges the result for its own tokens; the extension stores them and sends Authorization: Bearer <access token> on each call. Access tokens are short-lived (minutes), so a stolen one expires quickly; refresh tokens are long-lived but rotate on every use, so reuse of a stolen one is detectable. The server verifies the JWT’s signature and claims without a database lookup on every request.

Sign-in to your backendThe worker runs launchWebAuthFlow with PKCE, receives an authorisation code, posts it with the code verifier to your backend, which validates it and returns an access JWT and a refresh token, stored in extension storage.Service workerIdentity providerYour backendstoragelaunchWebAuthFlow (PKCE)redirect with codePOST /auth/exchange {code, verifier}redeem code{access_jwt, refresh_token}store tokens
The extension never holds a client secret; the backend mints its own tokens.

Step-by-step: tokens from sign-in to request

1. Sign in with PKCE and send the code to your backend

 1// sw.js
 2export async function signIn() {
 3  const { verifier, challenge } = await pkcePair();
 4  const redirect = chrome.identity.getRedirectURL("oauth");
 5  const auth = new URL("https://auth.readable.example/authorize");
 6  auth.search = new URLSearchParams({
 7    client_id: "readable-extension", response_type: "code", redirect_uri: redirect,
 8    scope: "openid profile offline_access", code_challenge: challenge, code_challenge_method: "S256",
 9    state: crypto.randomUUID(),
10  });
11  const result = new URL(await chrome.identity.launchWebAuthFlow({ url: auth.href, interactive: true }));
12  const res = await fetch("https://api.readable.example/auth/exchange", {
13    method: "POST", headers: { "Content-Type": "application/json" },
14    body: JSON.stringify({ code: result.searchParams.get("code"), verifier, redirect_uri: redirect }),
15  });
16  if (!res.ok) throw new Error(`exchange failed: ${res.status}`);
17  await saveTokens(await res.json());
18}

Execution context: the service worker. PKCE replaces the client secret a public client cannot keep; the verifier proves the code exchange comes from the same client that started the flow — see using PKCE with launchWebAuthFlow. Validate state against the value you generated before using the code. The redirect URL depends on the extension id, so each store’s build needs its own registered redirect.

2. Store tokens by sensitivity

1export async function saveTokens({ access_token, expires_in, refresh_token }) {
2  await chrome.storage.session.set({
3    access: { token: access_token, exp: Date.now() + expires_in * 1000 - 30_000 },
4  });
5  await chrome.storage.local.set({ refresh: refresh_token });      // survives restarts
6}

Execution context: the service worker. The access token goes in chrome.storage.session, which lives in memory and is hidden from content scripts by default. The refresh token must survive restarts, so it goes in chrome.storage.local; restrict that area to trusted contexts or encrypt the value if content scripts could otherwise read it, as covered in setting storage access level for content scripts. Subtracting thirty seconds from the expiry refreshes slightly early, avoiding requests that expire in flight.

Where each credential livesAccess token, refresh token, client id and client secret compared on lifetime, storage location and exposure.CredentialLifetimeStoreReadable by content scriptsAccess JWT5–15 minstorage.sessionNo (default)Refresh tokenDays–weeks, rotatingstorage.local, restrictedNo, if restrictedClient idPermanentPackagePublic anywayClient secret—Never in extension—
Short-lived in memory, long-lived on disk with restricted access, and no secrets at all.

3. Attach the token and refresh on demand

 1let refreshing = null;
 2
 3export async function getAccessToken() {
 4  const { access } = await chrome.storage.session.get("access");
 5  if (access && access.exp > Date.now()) return access.token;
 6  refreshing ??= refresh().finally(() => { refreshing = null; });
 7  return refreshing;
 8}
 9
10async function refresh() {
11  const { refresh: rt } = await chrome.storage.local.get("refresh");
12  if (!rt) throw Object.assign(new Error("signed out"), { code: "signed-out" });
13  const res = await fetch("https://api.readable.example/auth/refresh", {
14    method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: rt }),
15  });
16  if (res.status === 401) { await signOutLocally(); throw Object.assign(new Error("session expired"), { code: "signed-out" }); }
17  const tokens = await res.json();
18  await saveTokens(tokens);                              // includes the rotated refresh token
19  return tokens.access_token;
20}

Execution context: the service worker. Sharing one in-flight refresh promise prevents several concurrent requests from each spending the refresh token — with rotation, the second use would look like theft and revoke the session. A 401 from the refresh endpoint means the session is over; clear tokens and show “Sign in” rather than retrying.

4. Retry once on 401 from the API

 1export async function api(path, init = {}) {
 2  const call = async (token) => fetch(`https://api.readable.example${path}`, {
 3    ...init, headers: { ...init.headers, Authorization: `Bearer ${token}` },
 4  });
 5  let res = await call(await getAccessToken());
 6  if (res.status === 401) {
 7    await chrome.storage.session.remove("access");      // force refresh
 8    res = await call(await getAccessToken());
 9  }
10  return res;
11}

Execution context: the service worker. A 401 can mean the access token expired a moment early or was revoked; one forced refresh and retry covers the first case without looping on the second. Content scripts and pages call api through messages, never with tokens of their own.

Every API callRead the cached access token; if expired, refresh once with the rotating refresh token; call the API; on 401 force one refresh and retry; on a failed refresh, sign out locally.getAccessTokensession cacheexpired?refresh (shared promise)fetch + Beareryour API401Drop access tokenforce refreshRetry oncenew tokenRefresh 401sign out locally
One refresh at a time, one retry at most.

5. Verify JWTs on the server

 1// server (Node) using the jose library
 2import { createRemoteJWKSet, jwtVerify } from "jose";
 3const JWKS = createRemoteJWKSet(new URL("https://auth.readable.example/.well-known/jwks.json"));
 4
 5export async function authenticate(req) {
 6  const token = req.headers.authorization?.replace(/^Bearer /, "");
 7  const { payload } = await jwtVerify(token, JWKS, {
 8    issuer: "https://auth.readable.example",
 9    audience: "readable-api",
10  });
11  return { userId: payload.sub, scopes: String(payload.scope ?? "").split(" ") };
12}

Execution context: your backend. Verify signature, issuer, audience and expiry on every request; jose checks exp automatically. Do not accept the extension’s origin header as authentication — it is trivially spoofed outside a browser. Rate-limit by user id from the token, not by IP.

6. Sign out everywhere it matters

1export async function signOut() {
2  const { refresh: rt } = await chrome.storage.local.get("refresh");
3  if (rt) fetch("https://api.readable.example/auth/revoke", { method: "POST", body: JSON.stringify({ refresh_token: rt }) }).catch(() => {});
4  await chrome.storage.session.remove("access");
5  await chrome.storage.local.remove("refresh");
6}

Execution context: the service worker. Revoking the refresh token on the server ends the session for real; clearing local storage ends it on this device even if the network call fails. See signing users out and revoking tokens.

7. Keep tokens out of logs and errors

Error reports, analytics events and console logs are where tokens leak. Redact the Authorization header and any token fields in your error reporter’s beforeSend, never include request headers in breadcrumbs, and never put tokens in URLs — query strings end up in server logs and browser history.

Common mistakes

  • Shipping a client secret. Anything in the package is public; use PKCE.
  • Long-lived access tokens. A stolen hour-long token is an hour of access; keep them to minutes.
  • Concurrent refreshes. With rotation, the second refresh revokes the session.
  • Tokens in content scripts. They run in page renderers; keep tokens in the worker.
  • Trusting the extension origin on the server. Verify the JWT instead.

Cross-browser variation

  • Chrome / Edge: launchWebAuthFlow with https://<id>.chromiumapp.org/ redirects; each store id needs its own registered redirect.
  • Firefox: launchWebAuthFlow redirects to https://<hash>.extensions.allizom.org/, derived from the add-on id; register it separately. storage.session from 115.
  • Safari: launchWebAuthFlow is supported in recent versions; on older ones, open the sign-in page in a tab and capture the redirect with a content script or universal link through the containing app.

Verification

  1. Sign in and confirm storage.session holds an access token and storage.local a refresh token.
  2. Wait past the access token’s expiry and call the API: one refresh request, then success.
  3. Fire ten API calls simultaneously with an expired token: exactly one refresh request.
  4. Revoke the session server-side and confirm the next refresh fails and the UI shows “Sign in”.

FAQ

Sometimes, from extension pages with host permission — but cookie partitioning, SameSite rules and browser differences make it fragile. Tokens behave the same everywhere.

Where should the refresh token live in Safari?

The same place — storage.local — with the caveat that Safari’s access controls are coarser; consider shorter refresh lifetimes there.

Do I need my own identity provider?

No. Your backend can accept a third-party provider’s code or ID token, verify it, and issue its own JWTs.

Other Core APIs & Cross-Browser Data Management Resources