pushManager.subscribe() Fails With NotAllowedError

A NotAllowedError from pushManager.subscribe() is the browser refusing to create a subscription — almost always a permission, gesture, or context problem rather than a network or server fault.

Quick answer

pushManager.subscribe() throws NotAllowedError when the browser will not grant a push subscription. The common causes are: notification permission is not granted (it is default or denied), the call did not happen in response to a user gesture (required by Safari and increasingly by Chromium), the page is in an insecure context (not HTTPS or localhost), you passed userVisibleOnly: false without an allowance the browser grants, or the applicationServerKey is malformed or differs from an existing subscription. Request permission on a click, serve over HTTPS, and always pass userVisibleOnly: true with a correctly decoded VAPID key.

Why this happens

pushManager.subscribe() is gated by the Notifications permission and by the platform’s user-activation rules. The browser maps several distinct refusals onto the same NotAllowedError name, which is why the message alone rarely tells you the cause. The most frequent trigger is calling subscribe() while Notification.permission is still default (the user never answered) or denied (they said no) — the Push API will not silently create a subscription without an active grant. WebKit additionally requires that both the permission request and the subscribe call originate from a genuine user gesture, so calling them on page load or inside an async chain that has lost the activation throws.

Context matters too. Service workers and the Push API only function on secure origins, so a page served over plain HTTP (anything other than localhost) cannot subscribe. And userVisibleOnly: false — a request to send silent pushes — is refused by every major browser unless your origin has a specific allowance, surfacing as NotAllowedError. Finally, the applicationServerKey must be a Uint8Array derived from your base64url VAPID public key; a string, a wrong-length array, or a key that conflicts with an existing subscription is rejected. The full registration lifecycle is covered in service worker registration patterns and the Core Protocols & Browser Implementation overview.

The reason a single error name covers so many causes is historical: the Push API reuses the DOM NotAllowedError (a DOMException) to mean “the user agent declined this operation for a policy or permission reason.” That is intentionally broad. A denied permission, a missing user gesture, and a silent-push request are all, from the browser’s perspective, the same kind of refusal — the platform decided not to grant the capability. This is why reading err.message rather than err.name is essential: Chromium and Safari include human-readable detail in the message (“Registration failed - permission denied”, “Subscription failed - no active Service Worker”) that tells you which refusal you hit, even though the name is uniformly NotAllowedError.

The five refusals behind a single NotAllowedError A subscribe call that throws NotAllowedError fans out to five separate causes: permission not granted, missing user activation, insecure context, userVisibleOnly set to false, and a malformed or conflicting applicationServerKey. Each branch lists the check that confirms it. One error name, five refusals err.name is identical; err.message differs pushManager.subscribe() throws NotAllowedError Permission not granted Notification.permission is default or denied No user activation the request ran outside a real click or tap handler Insecure context window.isSecureContext is false — not HTTPS, not localhost userVisibleOnly: false silent push refused without an origin allowance Bad applicationServerKey a string, the wrong length, or a conflict with a live sub
The same NotAllowedError name covers five unrelated refusals — the message string, not the name, tells you which one you hit.

User activation deserves special attention because it is the cause developers most often miss. Browsers track a transient “user activation” flag that is set by a real input event (click, tap, key press) and consumed or expired shortly after. WebKit requires this flag to be present when requestPermission() runs, and the flag does not survive arbitrary await boundaries. A handler that awaits a network call, a serviceWorker.register(), or even a microtask chain before requesting permission can find the activation gone, producing a NotAllowedError that is impossible to reproduce when stepping through the debugger — because pausing restores focus and re-grants activation. Request permission synchronously at the top of the click handler, then do async work afterward.

How an await before requestPermission consumes user activation In the correct order the click handler calls requestPermission immediately while activation is still alive, then awaits the service worker and subscribes. In the failing order an awaited fetch runs first, the activation window expires, and the later requestPermission and subscribe calls throw NotAllowedError. User activation is consumed by awaits before requestPermission() Correct: permission first activation alive click handler requestPermission() await sw.ready subscribe() Throws: async work first activation expired click handler await fetch(/api/me) requestPermission() throws WebKit drops the activation across an await — request permission synchronously in the handler.
The transient user-activation window: awaiting anything before requestPermission() can consume it, and the refusal surfaces as NotAllowedError.

NotAllowedError versus the other subscribe exceptions

It helps to know what NotAllowedError is not, because misreading it sends you debugging the wrong layer. If subscribe() throws AbortError, the push service rejected the registration (often a transient backend issue or a bad endpoint) — that is a network/service problem, not a permission one. If it throws InvalidStateError, there is no active service worker controlling the page, so you must await navigator.serviceWorker.ready first. If it throws InvalidAccessError or a TypeError about the key, the applicationServerKey is the wrong type or length. Only NotAllowedError points at permission, gesture, context, or the userVisibleOnly policy. Branching on err.name lets you route each failure to the right fix instead of treating every subscribe failure as a permission denial. A SecurityError or a TypeError naming the worker path is a different problem again — the worker’s controlling scope, covered in service worker scope errors breaking push subscribe.

Routing subscribe exceptions to the right fix A three-column matrix. NotAllowedError maps to permission, gesture or context problems; AbortError to a push service refusal; InvalidStateError to a missing active service worker; InvalidAccessError or TypeError to a malformed applicationServerKey. Each row names the corresponding fix. err.name what actually failed the fix NotAllowedError a policy refusal permission, gesture, context or userVisibleOnly: false grant on a click, serve HTTPS, pass userVisibleOnly: true AbortError a service fault the push service refused the registration retry with backoff and re-check the endpoint host InvalidStateError a lifecycle fault no active service worker controlling the page await navigator .serviceWorker.ready first InvalidAccessError or a key TypeError applicationServerKey is the wrong type or length decode base64url to a 65-byte Uint8Array Only NotAllowedError points at permission, gesture or context — branch on err.name, never on the message alone.
Each subscribe exception names a different layer; only NotAllowedError is a permission, gesture or context refusal.

A frequently overlooked detail is that permission and subscription are separate states that can drift apart. A user can grant notification permission, get subscribed, then clear site data — which revokes the subscription but may leave the permission cached, or vice versa. So before subscribing, do not assume that a previous granted permission implies a live subscription, and do not assume an existing subscription implies current permission. Check both Notification.permission and pushManager.getSubscription() and reconcile them; a subscribe call made on a stale assumption is a common source of an unexpected NotAllowedError in returning-user flows. Reading the permission state before you ever call subscribe() is the cheapest guard available, and the techniques in detecting denied push permission without prompting let you branch on it silently.

Correct subscribe flow

The flow below requests permission on a real click, verifies the grant, serves the secure-context requirement implicitly (HTTPS), and decodes the VAPID key correctly before subscribing.

function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
  const raw = atob(base64);
  return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
}

// MUST be called from a click/tap handler, not on page load
async function subscribeToPush(vapidPublicKey) {
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
    throw new Error('Push not supported in this browser');
  }

  // 1. Permission must be granted BEFORE subscribe(); request it on the gesture
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') {
    // 'denied' or 'default' both cause NotAllowedError on subscribe()
    throw new Error(`Notification permission: ${permission}`);
  }

  const registration = await navigator.serviceWorker.ready;

  try {
    return await registration.pushManager.subscribe({
      userVisibleOnly: true, // false triggers NotAllowedError without an allowance
      applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
    });
  } catch (err) {
    if (err.name === 'NotAllowedError') {
      // Permission revoked mid-flow, no user gesture, or key/context problem
      console.error('subscribe blocked:', err.message);
    }
    throw err;
  }
}

On the server the public key must come from the environment — never hardcode the VAPID public key server-side. The key the client decodes must be the same one your server signs with; see VAPID key generation and rotation.

// Expose the public key to the client from env, not a literal
app.get('/vapid-public-key', (req, res) => {
  res.send(process.env.VAPID_PUBLIC_KEY);
});

Diagnostic steps

  1. Log Notification.permission before subscribing. If it is default, you never got a grant; if denied, the user blocked it and you cannot re-prompt programmatically. Both cause NotAllowedError.
  2. Confirm a user gesture. Ensure requestPermission() and subscribe() run synchronously inside a click handler. An await before the permission call can consume the activation in WebKit.
  3. Verify the secure context. Check window.isSecureContext. If false, move to HTTPS (or localhost for development) — the Push API will not work otherwise.
  4. Inspect userVisibleOnly. Make sure it is true. Setting it to false without a granted allowance throws on every major browser.
  5. Validate the applicationServerKey. It must be a Uint8Array of 65 bytes from your base64url VAPID public key, not a raw string. A decoding bug here is a quiet NotAllowedError.
  6. Check for an existing subscription with a different key. Call pushManager.getSubscription(); if one exists under a different applicationServerKey, unsubscribe it before subscribing with the new key.

Cause-to-fix reference

Cause How to confirm Fix
Permission default (never answered) Notification.permission === 'default' Call requestPermission() on a gesture first
Permission denied Notification.permission === 'denied' Cannot re-prompt; direct user to settings
No user gesture (WebKit) Error only outside a click handler Request permission synchronously in the handler
Insecure context window.isSecureContext === false Serve over HTTPS or use localhost
userVisibleOnly: false Flag is false in subscribe options Set it to true
Bad applicationServerKey Key is a string or wrong length Decode base64url to a 65-byte Uint8Array
Key mismatch with existing sub getSubscription() returns a sub unsubscribe() before re-subscribing

Work down this table in order: permission state and user gesture account for the overwhelming majority of real-world NotAllowedError reports, and both are cheap to check before you suspect the key or context.

Gotchas and edge cases

  • denied is sticky. Once a user denies notifications, you cannot re-prompt with JavaScript — requestPermission() resolves to denied immediately. Guide users to browser settings instead, using the recovery patterns in recovering users who blocked push permission.
  • Losing the user gesture across await. In Safari, awaiting something before requestPermission() can drop the user activation, turning a valid flow into a NotAllowedError. Request permission first.
  • iOS requires an installed PWA. On iOS, subscribe() only works inside a Home-Screen PWA; in a normal Safari tab it fails regardless of permission state — see iOS web push requires Add to Home Screen.
  • A string applicationServerKey silently fails or throws. It must be a Uint8Array (or ArrayBuffer). Passing the base64url string directly is a frequent cause.
  • Key mismatch on an existing subscription. A subscription is bound to the applicationServerKey it was created with; subscribing again with a different key requires unsubscribing first or it errors.

Back to Service Worker Registration Patterns

FAQ

Can I re-prompt for notification permission after the user denies it? No. Once permission is denied, Notification.requestPermission() resolves to denied immediately without showing a prompt, and pushManager.subscribe() keeps throwing NotAllowedError. The only route back is the browser's own site-settings UI, so surface an in-page explainer that walks the user there rather than retrying the API.
Does pushManager.subscribe() have to run inside a click handler? The permission request does. WebKit requires transient user activation for requestPermission(), and that activation does not survive an await, so call it synchronously at the top of the click handler. Once permission is granted, the subscribe() call itself can run later in the same async flow — but keeping both inside the gesture-initiated handler is the safest pattern across browsers.
Why does the same subscribe code work in Chrome but throw NotAllowedError on iOS Safari? On iOS, web push only works when the site has been installed to the Home Screen; in a regular Safari tab subscribe() is refused regardless of permission state. WebKit is also stricter about user activation than Chromium, so a handler that awaits a network call before requesting permission passes in Chrome and throws in Safari.