← Blog

The Clipboard Permissions API: Read, Write, and the States in Between

The first time you call navigator.clipboard.read() in production, the result depends on something you probably have not thought about: the user's browser permission state for clipboard access. Permissions can be granted, denied, or prompt (undecided) — and each one produces a different outcome. An app that treats all three identically will feel broken to at least one group of users.

This article is a tour of the Clipboard Permissions API: what the states mean, how to query and react to them, and the UX patterns that handle every path without surprising the user.

Two distinct permissions

Clipboard access splits into two permission keys:

  • clipboard-read — required for navigator.clipboard.read() and readText().
  • clipboard-write — required for navigator.clipboard.write() and writeText(), although many browsers grant this automatically in response to a user gesture.

In Chrome and Edge, both keys show up in navigator.permissions.query(). In Firefox, only clipboard-write is queryable and it is generally permitted without prompt inside a user gesture. Safari exposes neither through the Permissions API — it gates access through focus and gesture timing instead.

Querying the permission state

Before making a clipboard call, you can check whether it will succeed:

const status = await navigator.permissions.query({ name: 'clipboard-read' });
// status.state is 'granted', 'denied', or 'prompt'

The returned PermissionStatus object has a state field and an onchange handler. Wiring up onchange is worthwhile: a user might revoke permission mid-session through the browser's site-settings UI, and your app should react without requiring a reload.

What each state means

granted

The user has explicitly allowed the site to read the clipboard, or the site was installed as a PWA with clipboard access as part of its install manifest. You can call the API without any further prompt, but focus and secure-context rules still apply.

denied

The user has said no — either in a prior prompt or by flipping the site setting. Calling the API in this state throws NotAllowedError and the browser will not prompt again. The only recovery path is to tell the user where to change the setting.

prompt

No decision yet. If you call the API now, the browser shows a permission prompt. The call returns a promise that resolves if the user accepts or rejects with NotAllowedError if they refuse. Importantly, the prompt is only shown when inside an allowed user-gesture window — calling the API from a timer or an initial page-load script will still fail.

A robust call pattern

The pattern below covers all three states, handles the case where navigator.permissions itself is unsupported (Safari), and gives the user a recovery path when denied:

async function readClipboard() {
  if (!navigator.clipboard?.read) return showUnsupportedMessage();
  try {
    const items = await navigator.clipboard.read();
    return parseClipboardItems(items);
  } catch (err) {
    if (err.name === 'NotAllowedError') return showPermissionHelp();
    if (err.name === 'SecurityError') return showInsecureContextMessage();
    throw err;
  }
}

Triggering the call from a user gesture — a button click — is essential. If you call it on page load, every browser refuses regardless of permission state.

Write permission: subtler than it looks

clipboard-write is easier to acquire than clipboard-read. In a user gesture, most browsers will permit a write without any explicit prompt. The permission query will still return prompt because no stored decision has been made, but the call itself succeeds. A common mistake: gating your copy button UI on a granted permission state and hiding the button until the user triggers it once.

Just always show the button, and call navigator.clipboard.writeText() inside the click handler. It will work in Chrome, Firefox, and Safari without a prompt. Focus matters: if the page is not focused, writes also fail with NotAllowedError.

Secure contexts and localhost

Both clipboard-read and clipboard-write require a secure context — HTTPS or localhost. On plain HTTP, navigator.clipboard is either undefined or throws SecurityError on every call. This hits teams during deployment to staging environments that skip HTTPS; the app feels broken even though the code is correct.

Always feature-detect navigator.clipboard and surface a clear error if it is unavailable, rather than quietly failing.

Reacting to permission changes

If the user revokes permission from the browser's site-settings UI, the next clipboard call will throw. You can detect this earlier by subscribing to the PermissionStatus.onchange event and updating your UI immediately:

const status = await navigator.permissions.query({ name: 'clipboard-read' });
status.onchange = () => updateButtonState(status.state);

This lets you dim a "Paste from clipboard" button the moment permission flips to denied, rather than letting the user click it and fail.

User education: the bit most apps skip

A denied permission is a dead end unless you tell the user how to undo it. A sentence like "To enable clipboard access, click the lock icon in the address bar and change Clipboard to Allow" turns a denied state from a bug report into a self-service fix. Better still, show an image of the browser's UI for the user's detected browser (Chrome, Edge, Firefox) — the settings UIs differ enough that a generic instruction leaves some users stuck.

Verifying the full flow

The quickest way to see each state in practice is to trigger Clipboard Inspector through the "Read Clipboard via API" button in three browsing contexts: one where you have never granted permission (prompt), one where you have (granted), and one where you have revoked it (denied). The surfaced messages and DevTools console output will show exactly how each state behaves end-to-end.

For a deeper look at the trade-offs between the async API and the traditional paste event, see Clipboard API vs Paste Event.