Fixing NotAllowedError on navigator.share()

This guide is part of Understanding Secure Context Requirements, which itself is part of Web Share API & Security Contexts.

NotAllowedError from navigator.share() almost always means one thing: the browser no longer trusts that a real person triggered the call. The most common cause is a broken user-gesture chain — an await or a setTimeout between the tap and the share() invocation. Less often it is a Permissions-Policy block, a backgrounded tab on iOS, or platform rate limiting. This guide separates those causes and gives you the one structural fix that resolves the majority of cases: do all async work first, then invoke share() synchronously.


Why the gesture expires: broken vs. preserved gesture chain Two timelines. In the broken chain, a tap is followed by an awaited fetch, then a share call that fails with NotAllowedError. In the preserved chain, data is prepared before the tap, so the tap leads directly to a synchronous share call that succeeds. Broken chain tap await fetch(...) gesture expires here share() NotAllowedError Preserved chain prepare payload before the tap tap share() sync sheet opens

Feature Detection Gate

Wire the button only when the API is genuinely usable. This gate confirms the secure context and that navigator.share is callable, so you never attach a handler that can only fail:

export function canInvokeShare() {
  return (
    window.isSecureContext === true &&
    typeof navigator.share === 'function'
  );
}

If canInvokeShare() returns false, render a fallback control instead of a share button. Attaching a share handler to a button that cannot share is how most NotAllowedError reports begin.

Solution Walkthrough

Step 1 — Do the Async Work Before the Gesture, Not Inside It

The single most common NotAllowedError cause is awaiting inside the click handler. When you await a fetch, the microtask that resumes after it is no longer running under the user gesture. By the time navigator.share() executes, the transient activation has been consumed and the browser rejects the call. The fix is to resolve everything the share needs before the user taps:

let sharePayload = null;

export async function preparePayload(articleId) {
  // Runs on page load or hover — well before the tap.
  const res = await fetch(`/api/articles/${articleId}/share`);
  const data = await res.json();
  sharePayload = { title: data.title, text: data.summary, url: data.canonicalUrl };
}

export function onShareClick() {
  if (!canInvokeShare() || sharePayload === null) return;
  // No await before this line — the gesture is still live.
  navigator.share(sharePayload).catch((err) => {
    if (err.name === 'AbortError') return; // dismissed, not a failure
    console.error('share rejected:', err.name);
  });
}

Note that onShareClick is not declared async and contains no await before navigator.share(). That is deliberate: it keeps the invocation synchronous within the gesture. Handle the returned promise with .catch() on the promise object rather than awaiting it.

Step 2 — If You Must Await, Do It After Opening the Sheet

Sometimes the URL genuinely is not known until click time. In that case, keep any unavoidable work synchronous, or accept a small correctness trade-off by sharing what you have and refining later. What you must not do is block on the network. If a token or short link is required, mint it ahead of time and cache it:

const linkCache = new Map();

export async function warmShareLink(itemId) {
  if (linkCache.has(itemId)) return;
  const res = await fetch(`/api/shortlink?item=${encodeURIComponent(itemId)}`);
  const { url } = await res.json();
  linkCache.set(itemId, url);
}

export function shareItem(itemId, title) {
  if (!canInvokeShare()) return;
  const url = linkCache.get(itemId);
  if (!url) return; // not warmed yet — show a fallback rather than await here
  navigator.share({ title, url }).catch((err) => {
    if (err.name !== 'AbortError') console.error(err.name);
  });
}

Call warmShareLink() on pointerdown or mouseenter so the short link is ready by the time the click fires. The gesture stays intact because shareItem() never awaits.

Step 3 — Grant the web-share Permission in Embedded Contexts

If your share button lives in an iframe, Permissions-Policy blocks the Web Share API unless the embedding frame explicitly delegates it. The rejection surfaces as NotAllowedError. Add the allow attribute to the iframe and confirm no server header disables the feature for the origin:

<!-- The parent must delegate web-share to the child frame -->
<iframe
  src="https://embed.example.com/reader"
  allow="web-share"
></iframe>

Also check your response headers: a Permissions-Policy: web-share=() header disables the capability document-wide, and every call will reject with NotAllowedError regardless of gesture correctness. If you rely on strict feature detection first — as covered in how to fix navigator.share is undefined in Chrome — a policy block usually manifests at call time rather than as an undefined property, which is how you tell the two apart.

Failure Modes and Recovery

Error / Symptom Root Cause Minimal Fix
NotAllowedError after an await in the handler Gesture consumed by the awaited task Prepare payload beforehand; call share() synchronously
NotAllowedError from setTimeout/requestAnimationFrame wrapper Deferred call runs outside the activation Remove the timer; invoke directly in the click handler
NotAllowedError only inside an iframe Permissions-Policy strips web-share Add allow="web-share"; drop any web-share=() header
NotAllowedError on iOS when returning to a tab Tab backgrounded / not focused at call time Only fire from a foregrounded, visible tab
NotAllowedError after rapid repeated taps Platform rate limiting on share invocations Debounce the button; disable it while a sheet is open
AbortError (not NotAllowedError) User dismissed the sheet Swallow it — handle AbortError as normal

A subtle variant: calling navigator.share() a second time while the first sheet is still open can reject the second call. Disable the button on invocation and re-enable it in the promise settlement.

Browser and Platform Caveat

The gesture requirement is universal, but iOS Safari enforces it most aggressively. On iOS, any awaited work between the tap and the call — even a resolved Promise.resolve().then() in some builds — can invalidate the activation, whereas Chromium on Android is occasionally more forgiving. iOS additionally refuses shares from a backgrounded or non-visible tab, which is why a share fired from a visibilitychange handler or a deferred callback throws there but not always on desktop. Treat the synchronous-invocation pattern as mandatory across all platforms; it is the only approach that behaves consistently everywhere the API exists.

FAQ

Why does navigator.share() throw NotAllowedError even though the button is a real button?

A real button element is necessary but not sufficient. The call must run while the browser still considers the user gesture active, and any awaited work — a fetch, a resolved promise, a setTimeout — consumes that activation before share() runs. Prepare the payload ahead of the tap and invoke navigator.share() synchronously inside the handler.

Can Permissions-Policy cause NotAllowedError?

Yes. If the embedding iframe does not delegate the feature, or a Permissions-Policy: web-share=() header disables it for the origin, navigator.share() rejects with NotAllowedError. Add allow="web-share" to the iframe element and audit your response headers before assuming the problem is gesture-related.

Is NotAllowedError the same as AbortError?

No. NotAllowedError means the browser refused the call, usually because of a lost gesture or a policy block, and it is a defect you should fix. AbortError means the user opened the sheet and dismissed it, which is expected. Branch on error.name and treat only NotAllowedError as a bug.

Why does my share work on Android but throw NotAllowedError on iOS?

iOS Safari is stricter about both the gesture and tab focus. A share triggered after awaited work, or from a backgrounded tab, throws NotAllowedError on iOS while a looser Android build may still allow it. Keep the invocation synchronous and only fire it from a visible, foregrounded tab.