Exporting and Importing Extension Settings
Let users back up and restore extension settings as a file — a versioned export format, validating an untrusted import, merging versus replacing, and the file picker inside MV3 surfaces.
Table of Contents
Power users ask for it first: a way to move their configuration to a work machine where browser sync is disabled, to keep a backup before experimenting, or to share a curated rule list with a colleague. Export is a download; import is the dangerous half, because a file the user picked is untrusted input that is about to be written into storage your extension reads everywhere. This guide is part of options page configuration.
The shape of a good export file
An export is a contract with your future self. The version you ship today will be imported by a version you ship in two years, so the file needs to say what wrote it.
Step-by-step
1. Build the export from synced settings only
1// options.js
2async function buildExport() {
3 const { settings = {} } = await chrome.storage.sync.get("settings");
4 return {
5 format: "reader-settings",
6 schema: SCHEMA,
7 exportedBy: chrome.runtime.getManifest().version,
8 exportedAt: new Date().toISOString(),
9 settings,
10 };
11}
Execution context: the options page. Exporting only the user-level settings is deliberate — device-specific values, cached data and anything credential-shaped must never end up in a file the user may email to someone. The split between the two is set out in syncing options across a user’s devices.
2. Download it from the page
The options page has a DOM, so an anchor with an object URL is the simplest path and needs no permission.
1document.querySelector("#export").addEventListener("click", async () => {
2 const blob = new Blob([JSON.stringify(await buildExport(), null, 2)], { type: "application/json" });
3 const url = URL.createObjectURL(blob);
4 const a = Object.assign(document.createElement("a"), {
5 href: url,
6 download: `reader-settings-${new Date().toISOString().slice(0, 10)}.json`,
7 });
8 a.click();
9 setTimeout(() => URL.revokeObjectURL(url), 10_000);
10});
Execution context: the options page, inside a click handler. A download from the service worker would need chrome.downloads and a data: URL, as in managing downloads from an extension; from a page, the anchor is simpler and asks for nothing.
3. Import from a file the user picks — in a tab, not the popup
A file <input> opens a system dialog, which closes the popup and kills its change event. Import must live somewhere that survives focus loss: the options page opened as a tab, or a dedicated import page.
1<label for="import-file">Import settings</label>
2<input id="import-file" type="file" accept="application/json,.json">
1document.querySelector("#import-file").addEventListener("change", async (e) => {
2 const file = e.target.files?.[0];
3 if (!file) return;
4 if (file.size > 256 * 1024) return showError("That file is too large to be a settings export.");
5 const text = await file.text();
6 await importSettings(text);
7 e.target.value = ""; // allow re-importing the same file
8});
Execution context: the options page in a tab. The size check is a cheap first defence — a settings export is kilobytes, and a multi-megabyte file is either the wrong file or an attempt to make parsing expensive. The popup’s inability to host this is covered in why the popup closes and how to work with it.
4. Validate everything before writing anything
1async function importSettings(text) {
2 let data;
3 try { data = JSON.parse(text); } catch { return showError("This isn't a valid settings file."); }
4
5 if (data?.format !== "reader-settings") return showError("This file wasn't exported by Reader.");
6 if (!Number.isInteger(data.schema) || data.schema > SCHEMA) {
7 return showError("This file came from a newer version of Reader. Update the extension first.");
8 }
9
10 const migrated = migrateSettings(data.settings ?? {}, data.schema);
11 const clean = parseSettings(migrated); // the same parser used on every storage read
12 await applyImport(clean);
13}
Execution context: the options page. Reusing parseSettings — the function that turns an unknown stored value into a well-typed settings object — means an imported file is held to exactly the same rules as data already in storage. Its design is in defaulting and versioning an options schema.
5. Offer merge or replace, and confirm
1async function applyImport(incoming) {
2 const mode = await askUser(["Replace all settings", "Merge with current settings"]);
3 const { settings: current = {} } = await chrome.storage.sync.get("settings");
4 const next = mode === 0 ? incoming : {
5 ...current,
6 ...incoming,
7 enabledOrigins: [...new Set([...(current.enabledOrigins ?? []), ...(incoming.enabledOrigins ?? [])])],
8 };
9 await chrome.storage.local.set({ settingsBackup: { at: Date.now(), settings: current } });
10 await chrome.storage.sync.set({ settings: next });
11 showDone(`Imported ${Object.keys(incoming).length} settings.`);
12}
Execution context: the options page. Writing the previous settings to a local backup before replacing them costs one storage call and makes “undo import” possible — the kind of affordance users only notice when they need it.
Treat the file as hostile input
An imported file is not trusted just because the user chose it. It may have been edited, shared by someone else, or crafted deliberately, and its contents will flow into places that render and act on them.
Three categories of field need specific care:
- URLs and origins become match patterns and host permission requests. Validate each with
new URL()and restrict tohttp:andhttps:; an importedjavascript:origin must not survive. - Strings that are displayed — rule names, labels — will be rendered in your UI. Render them with
textContent, neverinnerHTML, as covered in sanitising untrusted page data in an extension. - Lists can be arbitrarily long. Cap them at what the product supports, and at what
storage.synccan hold.
An import must also never grant permissions by itself. If the imported settings enable sites the extension has no host access to, the import should record the intent and let the reconcile ask for access, one gesture at a time.
1const missing = clean.enabledOrigins.filter((o) => !granted.has(`${o}/*`));
2if (missing.length) showNotice(`${missing.length} sites need access. Grant it from the Sites tab.`);
Execution context: the options page after import. Requesting a batch of host permissions straight from an import is both a poor experience and exactly what a malicious settings file would want.
Cross-browser variation
- Chrome / Edge: anchor downloads from the options page work without the
downloadspermission. File inputs work in the options page and any extension page in a tab. - Firefox: identical for tab-hosted pages. Firefox’s embedded options frame in
about:addonscan host a file input more reliably than a popup, but opening import in its own tab is still the most predictable choice. - Safari: file inputs in extension pages work, but the options surface may be a separate window. Anchor downloads with
downloadattributes are honoured. - All three: exported files are portable between browsers only if the settings themselves are. Host permissions and browser-specific toggles should be ignored on import if the target browser does not support them.
Verification
- Export, change a setting, import the file with Replace, and confirm the setting reverted.
- Import a file with
"schema": 999and confirm the “newer version” message rather than a partial write. - Import a hand-edited file containing a
javascript:origin and confirm it is dropped:
1(await chrome.storage.sync.get("settings")).settings.enabledOrigins
2 .filter((o) => !/^https?:\/\//.test(o));
3// []
Execution context: the options page console after the import. Any entry here is a validation gap.
- Confirm the backup exists and that restoring it returns the exact pre-import state.
FAQ
Should exports be encrypted?
Not by default. A settings file that contains nothing sensitive does not need it, and an encrypted file the user cannot inspect is harder to trust. If an export must contain something sensitive, the design has gone wrong upstream.
Can I import settings exported from the Firefox version into Chrome?
Yes, if the format and schema are shared. Drop any keys that describe Firefox-only features rather than failing the import.
How do I support importing from a competitor’s extension?
Treat it as a separate format with its own converter into your current schema. Never try to guess a format — a misidentified file is worse than a rejected one.
Related
- Defaulting and versioning an options schema — the parser and migrations import reuses.
- Validating and resetting options forms — the form-level validation around the same data.
- Managing downloads from an extension — exporting from a context without a DOM.
- Options page configuration — the parent guide.