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.

Published September 18, 2026 Updated September 18, 2026 8 min read
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

Clipboard access by extension contextService worker, popup, offscreen document and content script compared on whether they can write, whether they can read, and the precondition for each.ContextWriteReadPreconditionService workerNoNoNo clipboard objectPopupYesWith permissionMust still be focusedOffscreen documentYesWith permissionreasons: CLIPBOARDContent scriptPage-dependentRarelyPage focused + policy
The popup is the easiest path when the action starts there; the offscreen document is the only path when it does not.

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.

A context-menu copy, end to endThe user picks a context-menu item, the worker computes the text, creates or reuses the offscreen document, which performs the copy and replies, and the worker confirms with a badge.UserService workerOffscreen documentToolbarcontextMenus.onClickedstripTracking(linkUrl)ensureOffscreen + clipboard:writetextarea + execCommand('copy'){ ok: true }badge ✓ for a minute
The only context that can do the actual write here is the one the user never sees.

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.

Picking the clipboard pathA decision tree choosing between a direct popup write, a rich ClipboardItem write, and an offscreen execCommand write based on where the action started and what format is needed.Where did the user trigger the copy?Popup or options pagenavigator.clipboarddocument is focusedClipboardItem for rich formatsinclude text/plainMenu, command, notificationOffscreen documentreasons: CLIPBOARDexecCommand('copy')plain text onlyInside the pageContent scriptpage focus requiredPrefer routing to the workerpage policy may block it
Where the action started decides the context; what is being copied decides the API.

Cross-browser variation

  • Chrome / Edge: clipboardWrite lets extension pages write without a transient-activation check. The offscreen CLIPBOARD reason exists specifically for worker-initiated copies. ClipboardItem is supported in focused extension pages.
  • Firefox: no offscreen documents, but the background event page has a DOM, so the textarea + execCommand path works there directly. navigator.clipboard.writeText in extension pages is permitted with clipboardWrite.
  • 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

  1. From the popup, copy and immediately paste into another application — the text should be exact.
  2. 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.

  1. Revoke clipboardRead and confirm the paste feature asks again rather than failing silently.
  2. 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.

Other MV3 Architecture & Extension Lifecycle Resources