Step-by-step guide to testing Web Share API on localhost

This guide is part of Configuring HTTPS for Local Development, itself a section of Web Share API & Security Contexts.

The problem this guide solves

http://localhost and 127.0.0.1 are treated as secure contexts by modern browsers, so window.isSecureContext returns true and navigator.share is available without any certificate work. Three specific scenarios still break this assumption: testing on a physical mobile device via your LAN IP (e.g. 192.168.x.x), using a custom .test or .local hostname, or running inside environments that do not honour the localhost exemption — certain enterprise Chromium builds, Android WebView, and older browser versions. If you hit any of those scenarios without a locally trusted certificate, the browser blocks navigator.share entirely and there is no JavaScript workaround.


Secure context decision flow for localhost testing Flowchart showing when http://localhost is already a secure context versus when mkcert HTTPS is required: LAN IP and custom domains require TLS certificates, while localhost and 127.0.0.1 are automatically trusted. Dev server origin? localhost / 127.0.0.1 Already secure ✓ LAN IP / custom domain isSecureContext = false Run mkcert + configure server navigator.share available ✓

Feature detection gate

Run this in DevTools Console before writing any code — it gives you a baseline reading:

export function checkShareEnvironment() {
  return {
    isSecureContext: window.isSecureContext,
    hasShare: typeof navigator.share === 'function',
    hasCanShare: typeof navigator.canShare === 'function',
    origin: location.origin
  };
}

If isSecureContext is false, no code change will make navigator.share available — you must fix the origin first. If hasShare is false on a secure context, the browser does not implement the API; route to the navigator.canShare() graceful fallback pattern instead.

Solution walkthrough

Step 1 — Confirm you actually need a certificate

Open DevTools Console at your dev origin and run:

console.log(window.isSecureContext); // true on http://localhost
console.log(typeof navigator.share); // 'function' if supported

If both pass on http://localhost, skip to Step 4. Only proceed to certificate setup if your dev URL is a LAN IP or custom domain.

Step 2 — Provision a locally trusted certificate with mkcert

mkcert creates a local certificate authority (CA) and issues certificates signed by it. Unlike a self-signed cert accepted by clicking through a browser warning, this CA is added to your OS trust store, which is what makes window.isSecureContext return true.

# macOS (Homebrew)
brew install mkcert
mkcert -install   # installs the local CA into OS + browser trust stores

# Generate certs covering localhost, loopback, and your LAN IP
mkcert localhost 127.0.0.1 192.168.1.100 ::1
# Outputs: localhost+3.pem  and  localhost+3-key.pem

Restart the browser after mkcert -install so it picks up the new CA entry.

Step 3 — Wire the certificates into your dev server

Vite (vite.config.js):

import { defineConfig } from 'vite';
import fs from 'node:fs';

export default defineConfig({
  server: {
    https: {
      key: fs.readFileSync('./localhost+3-key.pem'),
      cert: fs.readFileSync('./localhost+3.pem')
    },
    host: '0.0.0.0',  // required for LAN device access
    port: 3000
  }
});

webpack-dev-server (webpack.config.js):

const fs = require('fs');

module.exports = {
  devServer: {
    server: {
      type: 'https',
      options: {
        key: fs.readFileSync('./localhost+3-key.pem'),
        cert: fs.readFileSync('./localhost+3.pem')
      }
    },
    host: '0.0.0.0',
    port: 3000
  }
};

After restarting the server, load https://localhost:3000 (or https://192.168.1.100:3000 on a physical device). The browser should show a padlock, not a certificate warning, and window.isSecureContext should now be true.

Step 4 — Bind the share call to a direct user gesture

navigator.share() requires transient user activation — the browser only grants access when the call originates synchronously from a user input event such as click or touchstart. Calling it from setTimeout, after an await, inside useEffect, or from a framework lifecycle hook causes an immediate NotAllowedError.

export function attachShareHandler(buttonId, payload) {
  const btn = document.getElementById(buttonId);
  if (!btn) return;

  btn.addEventListener('click', async () => {
    if (!window.isSecureContext || typeof navigator.share !== 'function') {
      console.warn('[Share] API unavailable — check origin and browser support.');
      return;
    }

    try {
      await navigator.share(payload);
    } catch (err) {
      if (err.name === 'AbortError') return; // user dismissed the sheet
      console.error(`[Share] ${err.name}: ${err.message}`);
    }
  });
}

Notice that navigator.share() is called inside the click handler with no intermediate await before it. Awaiting anything — a feature check, an analytics call, a data fetch — before calling navigator.share() consumes the transient activation window and produces NotAllowedError.

Failure modes and recovery

Error Symptom Cause Fix
SecurityError navigator.share is undefined window.isSecureContext is false Use http://localhost or provision HTTPS with mkcert
NotAllowedError API present but call rejected No synchronous user gesture Call navigator.share() directly inside a click handler
NotAllowedError (certificate) Browser shows TLS warning; isSecureContext is false Certificate accepted via browser dialog, not OS trust store Run mkcert -install and restart the browser
TypeError Call throws with payload details navigator.canShare(data) would return false Validate with navigator.canShare() before calling navigator.share()
Stale HSTS block HTTP version of origin is blocked Browser remembered HSTS from a previous visit Clear HSTS state at chrome://net-internals/#hsts or use a fresh profile

For a complete mapping of all DOMException names the API can produce, see the understanding secure context requirements reference.

Browser and platform caveats

The localhost secure-context exemption is universally honoured on desktop Chrome, Edge, Firefox, and Safari. The exemption does not apply when accessing the dev server from a different device on the same LAN — http://192.168.x.x is not localhost from the perspective of the mobile browser making the request, so HTTPS is mandatory for any cross-device physical device testing. iOS Safari is the strictest runtime: it evaluates isSecureContext per-frame, so an HTTPS top-level page embedding an HTTP <iframe> will still report false inside that frame even if the outer page is secure. Android WebView behaviour depends on the host app; some builds do not honour the localhost exemption at all, making HTTPS a requirement even for same-device testing in that context. Check the browser support matrix for version-level detail on which environments support the API.

FAQ

Why is window.isSecureContext already true on http://localhost?

Browsers implement the W3C “potentially trustworthy origins” algorithm, which explicitly includes localhost and 127.0.0.1 as secure regardless of the protocol used. This is a deliberate exception to support local development without requiring TLS.

Why does navigator.share() throw NotAllowedError even in a secure context?

The most common cause is calling it outside a synchronous user gesture. The browser tracks transient user activation — once you await a promise or use setTimeout, the activation window may have expired. Invoke navigator.share() directly inside the event handler callback without any intermediate await.

Can I bypass the user gesture requirement in automated tests?

No. The Web Share API spec mandates transient activation to prevent abuse. For CI/CD testing, mock navigator.share using vi.fn() (Vitest) or jest.fn(), or use a Playwright/Cypress custom command that simulates a real click event.

My self-signed certificate works in the browser but window.isSecureContext is still false. Why?

The root CA must be installed at the OS trust store level, not just accepted in the browser’s certificate error dialog. Use mkcert -install to install the CA system-wide, then restart the browser. Accepting a certificate error dialog session does not establish a full secure context in all browsers.