Permission Flows & Progressive Enhancement

The Web Share API and related native device capabilities require explicit user consent and a verified secure context before a single share sheet call can succeed. This reference covers the full permission and progressive-enhancement architecture: from window.isSecureContext validation and gesture-gated invocation through to a layered fallback chain that keeps sharing functional on every browser, OS, and network condition.

Architecture Overview

Every integration in this section is built on four non-negotiable mandates:

  • Secure context firstwindow.isSecureContext must be true before any capability check or API call. HTTPS is required in production; localhost is treated as secure for development.
  • Feature detection, not browser sniffingnavigator.share and navigator.canShare(payload) are the canonical gates. Version strings and user-agent parsing break silently across rapid browser release cycles.
  • Progressive enhancement by default — the fallback path (clipboard, mailto:, sms:, QR code) is not an error state; it is a first-class code path exercised on every environment where native share is absent or denied.
  • Gesture-gated invocationnavigator.share() requires transient activation. Calling it outside a direct user interaction throws NotAllowedError before the OS share sheet is ever consulted.

API Surface Reference

Method / Property Signature Returns Throws
navigator.share() share(data?: ShareData): Promise<void> Promise<void> resolves on success NotAllowedError (no gesture / denied), AbortError (user dismissed), DataError (no valid field), TypeError (secure context missing)
navigator.canShare() canShare(data?: ShareData): boolean true if payload is shareable Does not throw; returns false for invalid/unsupported payloads
window.isSecureContext Read-only boolean true on HTTPS/localhost
ShareData.files File[] Only valid when navigator.canShare({ files }) returns true; file sharing is not universally supported
Permissions.query() query({ name: 'clipboard-write' }): Promise<PermissionStatus> PermissionStatus TypeError for unrecognised permission names

ShareData fields — title, text, url, files — are all optional individually, but at least one must be present and non-empty, otherwise navigator.canShare() returns false and navigator.share() throws DataError.

Secure Context Validation

Understanding secure context requirements is the prerequisite step before any code in this section runs. window.isSecureContext is the runtime gate; it returns false on plain HTTP origins (except localhost) and on any document served in an opaque origin (e.g. sandboxed iframes without allow-same-origin).

Mixed content complicates staging environments. A page served over HTTPS that loads a script or stylesheet over HTTP triggers mixed-content blocking, which can disable APIs in stricter browser configurations even when isSecureContext is technically true. Always mirror production TLS on staging and validate isSecureContext programmatically at module initialisation, not just at call time.

/**
 * Module-level secure-context guard.
 * Call once during app init — not inside the share handler.
 */
export function assertSecureContext() {
  if (!window.isSecureContext) {
    throw new Error(
      'Web Share API requires a secure context (HTTPS or localhost). ' +
      `Current origin: ${location.origin}`
    );
  }
}

Configuring HTTPS for local development walks through mkcert and Vite/webpack proxy setups that keep isSecureContext true during development without deploying to a remote server.

Feature Detection & Capability Mapping

Browser support for navigator.share varies substantially across platforms. The presence check alone is insufficient — iOS Safari supports navigator.share but rejects files on many versions; Android Chrome accepts files but enforces MIME-type allow-lists that differ from desktop Chrome. Always check navigator.canShare(payload) with the exact payload you intend to send before rendering the native share button.

/**
 * Returns a structured capability snapshot for the current runtime.
 * Cache this result for the session — capabilities do not change mid-session.
 */
export function evaluateShareCapabilities(testPayload) {
  if (!window.isSecureContext) {
    return { supported: false, reason: 'insecure_context' };
  }

  if (typeof navigator.share !== 'function') {
    return { supported: false, reason: 'api_absent' };
  }

  const payload = testPayload ?? {
    title: 'test',
    text: 'test',
    url: window.location.origin
  };

  const canShare = navigator.canShare?.(payload) ?? false;

  return {
    supported: true,
    canShare,
    supportsFiles: navigator.canShare?.({ files: [new File([''], 'test.txt')] }) ?? false
  };
}

Map MIME types and file extensions before constructing file payloads. Different operating systems expose different share targets at runtime. For file sharing, query navigator.canShare({ files: [file] }) at the point of file selection rather than at app initialisation, since the result depends on the specific file type.

Contextual Permission Prompts

The single largest variable in share permission grant rates is trigger timing. Permission prompts — and the OS share sheet itself — must be initiated by an explicit, synchronous user gesture. Designing contextual permission prompts that communicate the value of sharing before the share sheet appears reduces abandonment and builds user trust.

Pre-prompt UI patterns work because they establish intent before the OS interrupts with a system-level dialogue. A brief “Share this result?” affordance placed contextually next to the shareable content outperforms a generic toolbar share icon that appears before the user has seen the value of sharing.

Never chain permission requests within a single handler. If your flow requires both share permission and clipboard access, resolve the primary action first. Transient activation is consumed by the first async API call — a second await on a separate permission-requiring API in the same handler will fail.

/**
 * Gesture-gated share handler with serialised fallback.
 * Must be invoked directly from a user event handler — not from setTimeout or a resolved Promise chain.
 */
export async function handleShareWithFallback(payload) {
  const caps = evaluateShareCapabilities(payload);

  if (caps.supported && caps.canShare) {
    try {
      await navigator.share({
        title: payload.title ?? document.title,
        text: payload.text ?? '',
        url: payload.url ?? location.href
      });
      return { status: 'shared' };
    } catch (error) {
      if (error.name === 'AbortError') {
        // User dismissed the share sheet — not an error, do nothing
        return { status: 'cancelled' };
      }
      if (error.name === 'NotAllowedError') {
        // No gesture, or share blocked by browser policy — fall through to clipboard
      }
      // DataError, TypeError, or unknown — fall through
    }
  }

  // Fallback: clipboard copy (also requires gesture context, still valid here)
  try {
    await navigator.clipboard.writeText(payload.url ?? location.href);
    return { status: 'clipboard_copy' };
  } catch {
    return { status: 'fallback_required' };
  }
}

Permission Denial Recovery

When navigator.share() throws NotAllowedError or when navigator.canShare() returns false, the user must not reach a dead end. Handling permission denials gracefully covers the full recovery architecture: informative UI messaging, persisting denial state to sessionStorage to avoid repeated prompt loops, and routing to alternative sharing mechanisms.

Implement exponential backoff before re-prompting after a denial. The exponential backoff implementation guide provides a production-ready delay schedule based on localStorage timestamp tracking that prevents prompt fatigue across sessions.

/**
 * Reads persisted denial state to gate re-prompt eligibility.
 */
export function isSharePromptThrottled(storageKey = 'share_denied_at') {
  const deniedAt = localStorage.getItem(storageKey);
  if (!deniedAt) return false;

  const elapsed = Date.now() - Number(deniedAt);
  const backoffMs = 24 * 60 * 60 * 1000; // 24-hour minimum gap
  return elapsed < backoffMs;
}

export function recordShareDenial(storageKey = 'share_denied_at') {
  localStorage.setItem(storageKey, String(Date.now()));
}

Fallback Chain Architecture

The fallback chain is a first-class part of the progressive enhancement architecture, not an afterthought. Each tier in the chain should be independent — if one fails, the next activates automatically.

Share Fallback Decision Chain A flowchart showing the progressive fallback path from native Web Share API through clipboard copy, SMS/mailto URI schemes, and finally QR code generation. User triggers share isSecureContext + navigator.share + canShare(payload) yes navigator.share() AbortError Dismissed no / error NotAllowedError Clipboard copy blocked SMS / mailto URI fallback no client QR code

Tier 1 — Native share sheet: navigator.share() invoked on gesture when isSecureContext and canShare(payload) pass. Handles the widest possible OS share target list including messaging apps, AirDrop, and social apps.

Tier 2 — Clipboard copy: When native share is unavailable or throws NotAllowedError, navigator.clipboard.writeText() copies the URL. Requires the same gesture context. Display a “Link copied” confirmation inline.

Tier 3 — SMS and email fallback architectures: sms:?body=… and mailto:?subject=…&body=… URIs open the device’s native messaging or email client without any special API permission. These work on every browser that supports URI scheme handlers — which is effectively universal.

Tier 4 — QR code generation: Canvas-rendered QR codes let users transfer the URL to a secondary device without relying on OS-level integrations. This also covers screen-to-screen kiosk scenarios.

Offline Share Queue

Share attempts made when the device is offline or the network is degraded should not silently fail. The offline share queue implementation stores pending payloads in IndexedDB and retries via a service worker sync event when connectivity is restored.

Syncing offline share queues with service workers details the Background Sync API registration, retry limits, and payload expiration logic needed for a production-safe implementation.

/**
 * Enqueues a share payload for offline retry via Background Sync.
 * Call this when navigator.share() throws or when navigator.onLine is false.
 */
export async function enqueueOfflineShare(payload) {
  const db = await openShareQueueDB();
  await db.add('queue', {
    payload,
    queuedAt: Date.now(),
    attempts: 0
  });

  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('share-queue-sync');
  }
}

Cross-Browser Behaviour

Browser Desktop navigator.share Mobile navigator.share File sharing (files) canShare()
Chrome 89+ Yes (Windows/ChromeOS) Yes (Android) Yes (MIME allow-list applies) Yes
Chrome macOS No (macOS < macOS Monterey) No No
Safari 14+ No (desktop Safari) Yes (iOS 14+) Partial (limited MIME types) Yes (iOS 15.4+)
Firefox No No No No
Edge 93+ Yes (Windows) Yes (Android) Yes Yes
Samsung Internet 8.2+ Yes Yes Partial Yes

Key platform quirks:

  • iOS Safari invokes the system share sheet but filters file types aggressively. Test navigator.canShare({ files: [yourFile] }) on a real device before shipping file share features.
  • Android Chrome accepts text/plain, text/html, image/png, image/jpeg, application/pdf and several others. Exotic MIME types silently fall back to fewer share targets rather than throwing.
  • Desktop Chrome on Windows opens a native Windows share dialog. On macOS it is blocked below Monterey and still inconsistent on newer versions — always route macOS through the clipboard fallback.
  • Firefox has no navigator.share implementation on any platform as of mid-2026. All Firefox users hit the fallback chain.
  • Calling navigator.share() inside an iframe requires the allow="web-share" permission policy attribute on the <iframe> element; omitting it causes NotAllowedError even with a valid gesture.

Progressive Enhancement Decision Checklist

Apply this checklist at the component level — not globally — so each share trigger evaluates capabilities independently:

  1. Call assertSecureContext() at module init. Throw if false; the component should not render at all on insecure origins.
  2. Check typeof navigator.share === 'function'. If absent, skip to step 5.
  3. Check navigator.canShare(payload) with the actual runtime payload. If false, skip to step 5.
  4. Bind navigator.share(payload) to the gesture handler. Catch AbortError silently (user chose not to share), NotAllowedError (fall to step 5), DataError (fix payload, retry once, then fall to step 5).
  5. Attempt navigator.clipboard.writeText(url). If Permissions API indicates denied, skip to step 6.
  6. Render sms: and mailto: URI links as anchor elements — no JS required, always available.
  7. Render QR code via Canvas API as the final universal option.
/**
 * Dynamically renders share controls based on runtime capabilities.
 * Each control tier is rendered independently so partial support is handled gracefully.
 */
export function initShareUI(containerEl) {
  if (!window.isSecureContext) return;

  const nativeBtn = containerEl.querySelector('[data-action="native-share"]');
  const clipboardBtn = containerEl.querySelector('[data-action="clipboard-share"]');
  const uriLinks = containerEl.querySelector('[data-action="uri-fallbacks"]');
  const qrContainer = containerEl.querySelector('[data-action="qr-share"]');

  const { supported, canShare } = evaluateShareCapabilities();

  if (supported && canShare) {
    nativeBtn?.removeAttribute('hidden');
    nativeBtn?.addEventListener('click', () => handleShareWithFallback(buildPayload()));
  } else {
    // Always show clipboard and URI fallbacks when native share is absent
    clipboardBtn?.removeAttribute('hidden');
    uriLinks?.removeAttribute('hidden');
    qrContainer?.removeAttribute('hidden');
  }
}

Error Handling Reference

DOMException name navigator.share() condition Recovery
NotAllowedError Called outside user gesture; or share blocked by browser/OS policy Fall to clipboard, then URI fallbacks. Record denial timestamp via recordShareDenial() to throttle re-prompts.
AbortError User dismissed the share sheet without selecting a target No recovery needed — this is an intentional user action. Do not show an error.
DataError Payload has no valid field (title, text, or url all absent or empty); or files present but canShare() returned false Validate payload before calling share(). Fix the missing field. If files cause the error, strip them and retry with URL only.
TypeError Not a secure context; or data argument is not a valid ShareData object Check window.isSecureContext first. Validate object shape before invocation.
InvalidStateError Document is not in a fully active state (e.g., called from a backgrounded page) Defer the share action until the page regains focus via the Page Visibility API.

Telemetry and Permission State Observability

Track PermissionState transitions (promptgranteddenied) without storing identifiable user data. Aggregate metrics at the session level.

/**
 * Structured telemetry event for share outcome tracking.
 * Replace the console.log with your analytics sink.
 */
export function trackShareOutcome(outcome, method) {
  const event = {
    event: 'share_outcome',
    outcome, // 'shared' | 'cancelled' | 'clipboard_copy' | 'fallback_required'
    method,  // 'native' | 'clipboard' | 'sms' | 'mailto' | 'qr'
    timestamp: Date.now(),
    // Do not include URL, title, or any user-identifiable payload fields
  };
  console.log('[share-telemetry]', event);
  // analytics.track(event);
}

Monitor fallback activation rates. A high rate of clipboard_copy or uri_fallback outcomes on Android Chrome indicates either that canShare() is returning false for your payload format (fix the MIME type or strip unsupported fields) or that gesture binding is broken (the handler is not executing synchronously from the click event).

Common Pitfalls

  • Requesting share on page load — this always throws NotAllowedError and trains users to expect browser permission blocking on your domain.
  • Calling navigator.canShare() with a hypothetical payload at module init instead of the actual runtime payload — file types and MIME support vary per payload.
  • Assuming HTTPS guarantees isSecureContext — sandboxed iframes without allow-same-origin report false regardless of origin.
  • Blocking the main thread between the gesture event and the navigator.share() call — any asynchronous gap (even await someCheck()) that crosses a task boundary may consume the transient activation before share() is called.
  • Failing to persist denial state across navigation, leading to repeated NotAllowedError loops on every page visit.
  • Omitting allow="web-share" on <iframe> elements — permission policy blocks navigator.share() inside iframes by default.

FAQ

Why must permission requests be tied to user gestures?

Browsers enforce transient activation requirements to prevent abusive pop-ups and confirm explicit user intent. navigator.share() called outside a gesture context throws NotAllowedError immediately. This also impacts permission grant rates: prompts triggered by direct user action convert significantly better than those shown on page load.

How does progressive enhancement apply to the Web Share API?

Progressive enhancement means the core sharing action always works. The experience upgrades to the native OS share sheet where navigator.share is available and navigator.canShare(payload) returns true. When those conditions fail it degrades through clipboard copy, mailto:/sms: URI schemes, or a QR code — the user is never left at a dead end.

What is the impact of mixed content on secure context validation?

window.isSecureContext reflects the page’s own origin, not individual sub-resources. Mixed content (HTTP assets on an HTTPS page) does not immediately flip isSecureContext to false, but it degrades effective security and in some browsers disables specific APIs. Maintain strict asset integrity and keep all sub-resources on HTTPS.

Can I call navigator.share() and navigator.clipboard.writeText() in the same gesture handler?

Yes, but only serially. Transient activation is consumed by the first asynchronous API call that requires it. await navigator.share() first; if it throws, the catch block can then invoke clipboard.writeText() as the fallback. Calling both concurrently with Promise.all() will cause one to fail with NotAllowedError.