Safely Using Remote Config Without Remote Code

Change MV3 extension behaviour after shipping without violating the remote code rules: a data-only schema, allow-list validation, safe caching, and the line reviewers actually draw.

Published July 25, 2026 Updated July 25, 2026 8 min read
Table of Contents

You need to change a threshold, add a blocked host, or turn a feature off for everyone — without shipping an update and waiting for review. That is legitimate, and it is also one step away from the thing MV3 prohibits outright: fetching something that gets interpreted as instructions. The distinction reviewers apply is not about file extensions, it is about whether the payload can change what your code does as opposed to what it operates on. This page is part of Extension Security & CSP Hardening and draws that line precisely.

Root cause: the rule is about interpretation, not transport

Fetching JSON is fine. Fetching JSON that contains a field your code passes to new Function, or a selector list your code compiles into behaviour it did not previously have, is remote code with extra steps. The policy already blocks the obvious forms — eval is unavailable in extension pages — so the risk that remains is architectural: a configuration format expressive enough to describe new behaviour is a language, and shipping an interpreter for it is what gets rejected.

The spectrum from data to codeNumeric thresholds and host lists are data; templates and expression strings are a language; a fetched script is code. Reviewers draw the line above the middle layer.Scalars and enumspollMinutes, maxItems, modeclearly dataAllow listshosts, ids, feature flagsclearly dataTemplates and expressionsstrings your code interpretstreated as codeFetched script or moduleeval, import(), script tagprohibited outright
Anything at or below the middle layer needs a very good reason — and an interpreter you wrote is still an interpreter.

Step 1. Define the schema in the extension, not on the server

The shipped code decides what fields exist and what values are acceptable. The server can only choose among those values. That inversion is what keeps the configuration data — a field the extension does not know about cannot influence anything.

 1// config-schema.js — the shipped contract
 2export const DEFAULTS = Object.freeze({
 3  pollMinutes: 30,
 4  maxItems: 50,
 5  mode: "balanced",             // one of a fixed set, never an arbitrary string
 6  blockedHosts: [],
 7  features: Object.freeze({ digest: true, preview: false }),
 8});
 9
10const MODES = new Set(["off", "balanced", "aggressive"]);
11const HOST = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
12
13export function sanitise(raw) {
14  const pollMinutes = Number(raw?.pollMinutes);
15  const maxItems = Number(raw?.maxItems);
16
17  return {
18    pollMinutes: Number.isFinite(pollMinutes) ? Math.min(Math.max(pollMinutes, 15), 1440) : DEFAULTS.pollMinutes,
19    maxItems: Number.isFinite(maxItems) ? Math.min(Math.max(maxItems, 1), 500) : DEFAULTS.maxItems,
20    mode: MODES.has(raw?.mode) ? raw.mode : DEFAULTS.mode,
21    blockedHosts: Array.isArray(raw?.blockedHosts)
22      ? raw.blockedHosts.filter((h) => typeof h === "string" && HOST.test(h)).slice(0, 500)
23      : DEFAULTS.blockedHosts,
24    features: {
25      digest: typeof raw?.features?.digest === "boolean" ? raw.features.digest : DEFAULTS.features.digest,
26      preview: typeof raw?.features?.preview === "boolean" ? raw.features.preview : DEFAULTS.features.preview,
27    },
28  };
29}

Execution context: Shared module imported by the service worker. Every field is read explicitly, coerced, and clamped or matched against a fixed set — an unknown key in the response simply never appears in the output, which is the property that makes the whole approach defensible. The clamps are not decoration either: a pollMinutes of 0 from a compromised endpoint would otherwise become a hot loop of alarm firings.

Step 2. Fetch it narrowly and cache the last good copy

A configuration fetch should be boring: one origin declared in connect-src, no credentials, a short timeout, and a fallback to whatever worked last time. Never let a failed fetch reset a user to defaults.

 1// config.js
 2const CONFIG_URL = "https://api.example.com/v1/config";
 3const MAX_AGE_MS = 6 * 60 * 60 * 1000;
 4
 5export async function loadConfig() {
 6  const { config, configFetchedAt = 0 } = await chrome.storage.local.get(["config", "configFetchedAt"]);
 7  const fresh = Date.now() - configFetchedAt < MAX_AGE_MS;
 8  if (config && fresh) return config;
 9
10  try {
11    const controller = new AbortController();
12    const timer = setTimeout(() => controller.abort(), 8000);
13    const response = await fetch(CONFIG_URL, {
14      credentials: "omit",
15      cache: "no-store",
16      signal: controller.signal,
17    });
18    clearTimeout(timer);
19    if (!response.ok) throw new Error(`status ${response.status}`);
20
21    const next = sanitise(await response.json());
22    await chrome.storage.local.set({ config: next, configFetchedAt: Date.now() });
23    return next;
24  } catch (error) {
25    console.warn("config refresh failed; keeping last known good", error);
26    return config ?? DEFAULTS;   // never regress a live user to defaults on a network blip
27  }
28}

Execution context: Extension service worker. credentials: "omit" keeps the request from carrying ambient cookies, which also stops the response varying per user in ways your schema has not accounted for. The AbortController timeout matters because the fetch may be on the critical path of a cold start; without it a hanging request can hold a handler open for the full invocation cap. Declaring the origin in connect-src, as shown in the hardening guide, turns a typo in CONFIG_URL into an immediate policy error rather than a silent request elsewhere.

The refresh path, including every failureA cached fresh config is used directly; otherwise a bounded fetch is sanitised and stored, and any failure falls back to the last good copy.Read cacheconfig + fetchedAtFresh?under 6 hoursBounded fetch8 s timeout, no credentialswhatever happens, something usable is returnedSanitised + storedschema appliedLast known goodon any failureShipped defaultsfirst run, offline
Three of the four exits return a usable configuration — only a first run with no network reaches the shipped defaults.

Step 3. Express feature changes as flags, not as behaviour

A flag selects between code paths that already exist in the shipped bundle. That is the whole trick: the remote value chooses, the extension decides what the choices are.

 1// The shipped bundle contains every branch. The config only picks one.
 2const STRATEGIES = {
 3  off: () => [],
 4  balanced: (items) => items.slice(0, 20),
 5  aggressive: (items) => items,
 6};
 7
 8export function selectItems(items, config) {
 9  // A mode the bundle does not implement can never be selected: sanitise() rejected it already.
10  return (STRATEGIES[config.mode] ?? STRATEGIES.balanced)(items);
11}

Execution context: Extension service worker or any extension page. Because the strategy table is a literal in the shipped code, a reviewer can read every behaviour the extension is capable of without consulting your server — which is precisely the property the remote code rule exists to preserve. The ?? STRATEGIES.balanced fallback is belt and braces on top of the schema check; two independent guards on the same value is appropriate when one of them is remote input.

Step 4. Know which shapes get rejected

Some configuration designs are rejected even though they never call eval, because they are interpreters. Recognising the shape is easier than arguing the case at review.

Configuration shapes and how they are readCommon remote configuration designs classified as data or as remote code, with the reason each is treated that way.Remote payloadClassified asWhy{ pollMinutes: 45 }DataSelects a value{ mode: 'aggressive' }DataSelects a shipped branch{ blockedHosts: [...] }DataOperand, not logic{ rule: 'item.score > 3' }CodeNeeds an evaluator{ template: '<div>{{x}}</div>' }CodeNeeds an interpreter{ scriptUrl: 'https://…' }CodeRemote code outright
The test is whether the payload can make the extension do something the shipped code does not already describe.

The fourth and fifth rows are the ones that trip up otherwise careful extensions. A tiny expression language feels like configuration when you write the parser, but a reviewer reads it as a way to ship logic without review — and they are right. If you genuinely need user-supplied expressions, that is a different problem with a different answer: a sandboxed page, which has no chrome.* access and no extension origin, so a successful escape reaches nothing.

Cross-browser variation

  • Chrome 120+: remote code is prohibited by policy and by store rules. connect-src narrowing is honoured, so an undeclared origin fails as a policy error rather than a network error.
  • Firefox 121+: the same prohibition, and AMO reviewers may additionally ask for build sources to confirm the shipped bundle matches the repository — a data-only configuration makes that conversation short.
  • Safari 17+: the same rules apply, with the additional constraint that App Store review cadence makes remote configuration more valuable and equally more scrutinised.
  • All engines: a fetched JSON file is data no matter what it is named. What matters is whether your code interprets it as instructions.

Verification

  1. Point CONFIG_URL at a response containing unknown keys and hostile values — a negative pollMinutes, a mode you do not implement, a host with a wildcard. The sanitised result must equal your defaults for those fields.
  2. Block the endpoint in DevTools and confirm the extension continues on the last known good configuration rather than reverting to defaults.
  3. Point the URL at an origin missing from connect-src and confirm you get a policy violation, proving the narrowing is active.
  4. Grep the built bundle for eval, new Function and dynamic import( with a non-literal argument. All three must be absent.

FAQ

Is fetching a JSON file remote code?

No. Data is fine and always has been. It becomes remote code when the payload determines behaviour your shipped bundle does not already contain — most often through a template or expression string that your own code interprets.

Can I ship feature flags this way?

Yes, and it is the canonical use. A flag selects between branches that a reviewer can read in your bundle, which is exactly the property the rule protects.

What about a rules engine driven by remote rules?

Depends entirely on the vocabulary. A fixed set of predicates chosen by name is data; an expression grammar is a language. If you cannot enumerate every behaviour the payload can produce, treat it as code.

How often should the extension refresh?

Rarely enough that it is not on the critical path of ordinary events — six hours or a day is typical, driven by an alarm rather than a timer. The cached last-good copy is what makes an infrequent refresh safe.

Other MV3 Architecture & Extension Lifecycle Resources