Reading and Writing the Clipboard in MV3
Copy and paste from a Manifest V3 extension — why the service worker cannot, when the popup can, the offscreen CLIPBOARD reason, and the permission each path needs.
Table of Contents
“Copy to clipboard” is one line in a web page and a small architecture problem in an extension. The service worker has no navigator.clipboard at all; the popup has one but loses focus the instant the user clicks away; a content script has the page’s clipboard access, which depends on the page’s focus and the page’s permissions policy. Which context should do the copy depends on where the action started. This guide is part of offscreen documents and DOM access.
Which context can touch the clipboard
Step-by-step
1. Declare the permissions you need
1{
2 "permissions": [
3 "clipboardWrite", // write without a user-gesture check in extension pages
4 "offscreen" // only if writes can start outside a visible page
5 ],
6 "optional_permissions": ["clipboardRead"] // reading is sensitive — ask when needed
7}
Execution context: parsed at install. clipboardWrite produces no install warning; clipboardRead produces a prominent one (“Read data you copy and paste”), which is why it belongs in optional_permissions and should be requested at the moment a paste feature is used.
2. Copy from the popup when the action starts there
If the user clicked a “Copy” button in the popup, the popup is focused and has a live gesture. Do the write there, directly.
1document.querySelector("#copy").addEventListener("click", async () => {
2 const { report } = await chrome.storage.session.get("report");
3 await navigator.clipboard.writeText(report ?? "");
4 status.textContent = "Copied";
5});
Execution context: the popup document. The write must complete before focus moves — the popup is destroyed on blur, and a promise pending at that moment is abandoned. Reading the value from storage first keeps the path free of a worker round trip, the approach described in loading popup data without a flash of empty UI.
3. Copy from the offscreen document when it starts elsewhere
A context-menu click, a keyboard command or a notification button all start in the service worker, which cannot write. Route through an offscreen document.
1// service worker
2chrome.contextMenus.onClicked.addListener(async (info) => {
3 if (info.menuItemId !== "copy-clean-link") return;
4 const clean = stripTracking(info.linkUrl);
5 await ensureOffscreen(["CLIPBOARD"]);
6 await chrome.runtime.sendMessage({ target: "offscreen", type: "clipboard:write", text: clean });
7});
Execution context: the service worker, woken by the menu click. ensureOffscreen is the idempotent creator from creating and closing offscreen documents.
1// offscreen/host.js
2chrome.runtime.onMessage.addListener((msg, _s, respond) => {
3 if (msg?.target !== "offscreen" || msg.type !== "clipboard:write") return false;
4 const ta = document.createElement("textarea");
5 ta.value = msg.text;
6 document.body.append(ta);
7 ta.select();
8 const ok = document.execCommand("copy");
9 ta.remove();
10 respond({ ok });
11 return true;
12});
Execution context: the offscreen document. navigator.clipboard.writeText requires the document to be focused, and an offscreen document never is — which is why the older execCommand("copy") path is the one that works here. It is deprecated for web pages but remains the supported route in this context.
4. Read only with permission and only on request
1async function pasteFromClipboard() {
2 const granted = await chrome.permissions.request({ permissions: ["clipboardRead"] });
3 if (!granted) return null;
4 await ensureOffscreen(["CLIPBOARD"]);
5 const { text } = await chrome.runtime.sendMessage({ target: "offscreen", type: "clipboard:read" });
6 return text;
7}
Execution context: initiated from an extension page inside a click handler, since permissions.request needs a gesture. The read itself happens in the offscreen document with a textarea and execCommand("paste"), which is only permitted when clipboardRead is held.
5. Confirm to the user without a notification
A copy the user cannot see happened is a copy they will repeat. The badge is the least intrusive confirmation available from a worker-initiated action.
1await chrome.action.setBadgeText({ text: "✓" });
2await chrome.action.setBadgeBackgroundColor({ color: "#0d9488" });
3chrome.alarms.create("clear-badge", { delayInMinutes: 1 });
Execution context: the service worker. An alarm rather than a setTimeout clears the badge, because the worker will likely be evicted before a timer fires — the distinction from alarms vs setTimeout in service workers.
Rich content and formats
Plain text covers most uses. Where the extension needs to put rich content on the clipboard — a formatted citation, a table that pastes into a spreadsheet — ClipboardItem carries multiple representations, and the receiving application picks the best one it understands.
1// popup or options page — a focused document is required for this API
2const html = `<a href="${escapeAttr(url)}">${escapeHtml(title)}</a>`;
3await navigator.clipboard.write([
4 new ClipboardItem({
5 "text/plain": new Blob([`${title} — ${url}`], { type: "text/plain" }),
6 "text/html": new Blob([html], { type: "text/html" }),
7 }),
8]);
Execution context: a focused extension page. ClipboardItem is not usable from an offscreen document because it goes through navigator.clipboard, which requires focus — so rich copies are limited to actions the user starts in your own visible UI. The HTML must be escaped: this is page-derived data being turned into markup, the concern set out in sanitising untrusted page data in an extension.
Two practical notes. Always include a text/plain representation — many targets accept nothing else. And keep the payload modest; a multi-megabyte clipboard write is slow and some platforms truncate it silently.
Cross-browser variation
- Chrome / Edge:
clipboardWritelets extension pages write without a transient-activation check. The offscreenCLIPBOARDreason exists specifically for worker-initiated copies.ClipboardItemis supported in focused extension pages. - Firefox: no offscreen documents, but the background event page has a DOM, so the
textarea+execCommandpath works there directly.navigator.clipboard.writeTextin extension pages is permitted withclipboardWrite. - Safari: clipboard access from a background context is heavily restricted. Keep copies in the popup or an extension page; a worker-initiated copy may not be achievable at all, which is a legitimate reason to hide the context-menu variant on Safari.
- All three: reading the clipboard is sensitive everywhere and is increasingly gated behind explicit user prompts. Never read speculatively.
Verification
- From the popup, copy and immediately paste into another application — the text should be exact.
- Trigger the context-menu path with the popup closed and confirm the clipboard holds the cleaned URL:
1await chrome.runtime.getContexts({ contextTypes: ["OFFSCREEN_DOCUMENT"] });
2// [{ contextType: "OFFSCREEN_DOCUMENT", documentUrl: "chrome-extension://…/offscreen/host.html" }]
Execution context: the service worker console, just after the copy. The document should exist while in use and close itself after its idle period.
- Revoke
clipboardReadand confirm the paste feature asks again rather than failing silently. - Paste a rich copy into a word processor and a plain-text editor and confirm each picks the right representation.
FAQ
Why does navigator.clipboard.writeText reject in the offscreen document?
Because the Async Clipboard API requires the document to have focus, and a hidden document never does. Use execCommand("copy") on a selected textarea in that context.
Does clipboardWrite need a justification in review?
It is low-risk and rarely questioned. clipboardRead is another matter — it needs a clear, user-visible feature behind it, as discussed in writing a permission justification that passes.
Can a content script copy on the page’s behalf?
Sometimes, if the page is focused and its permissions policy allows clipboard access. It is unreliable across sites; route the copy through the worker and offscreen document instead.
How do I clear sensitive data I put on the clipboard?
Overwrite it after a delay — a password manager’s pattern. Schedule an alarm for a minute later and write an empty string from the offscreen document, but only if the clipboard still holds your value; overwriting something the user copied since would be worse than leaving yours there. Checking requires clipboardRead, so for most extensions the honest option is simply not to put secrets on the clipboard at all.
Related
- Creating and closing offscreen documents — the host this depends on.
- Handling selection and link context menu clicks — where most worker-initiated copies begin.
- Offscreen alternatives in Firefox and Safari — the same feature without the API.
- Offscreen documents and DOM access — the parent guide.