skip to content

Permissions and Secure Contexts

You will learn why powerful APIs refuse to run outside HTTPS and how the permission and user-activation gates work. Interviewers ask because 'it works on localhost but not staging' has this exact cause more often than any other.

on this pageshow

questions

5

A page calls navigator.clipboard.writeText() and registers a service worker. Both work when the site is served from http://localhost:3000, but on http://staging.internal the browser reports navigator.clipboard and navigator.serviceWorker as undefined. What rule is the browser applying, and which origins satisfy it?

level: juniorimportance: must knowfreq 68%

answer

  1. not a bug, a gating rule
  2. scheme and host decide it
  3. loopback is the exception
  4. window.isSecureContext
  5. ancestors must qualify too

basics

~20 s

Powerful browser APIs are gated on secure contexts: HTTPS and WSS origins plus loopback hosts (localhost, 127.0.0.1, ::1). Plain HTTP on staging is not one, so those APIs are absent entirely; window.isSecureContext reports the verdict.

solid answer

~40 s

Those APIs are restricted to **secure contexts**. A browsing context is secure when its own origin is potentially trustworthy — an `https:` or `wss:` origin, or a loopback host such as `localhost`, `127.0.0.1` or `::1` — and every ancestor document is too. `http://staging.internal` is plain HTTP on a non-loopback host, so the whole class of gated APIs is simply not attached: `navigator.serviceWorker`, `navigator.clipboard` and `crypto.subtle` are `undefined`, which is why it reads as a missing feature rather than a permission error. Older APIs that predate the rule, like `navigator.geolocation`, still exist but fail at call time. The check is `window.isSecureContext`, also available inside workers. The fix is a real certificate on staging — the loopback exemption exists so local development needs no certificate, and no other private address or invented hostname inherits it.

code

javascript · 12 lines
javascript
function copyToClipboard(text) {
  if (!window.isSecureContext) {
    console.warn('Insecure origin: clipboard API unavailable on', location.origin);
    return Promise.resolve(false);
  }
  if (!navigator.clipboard?.writeText) {
    return Promise.resolve(false);
  }
  return navigator.clipboard.writeText(text).then(() => true, () => false);
}

copyToClipboard('hello').then((ok) => console.log('copied:', ok));

go deeper

for a junior

Be able to say plainly that modern browser APIs need HTTPS, that localhost is deliberately exempt so you can develop without a certificate, and that the symptom is usually an undefined object rather than an error message.

for a middle

Explain what makes an origin potentially trustworthy — scheme plus loopback host — and why a LAN IP or a .internal hostname is excluded. Mention window.isSecureContext and why some APIs disappear while older ones fail at call time.

for a senior

Show you can diagnose this from a bug report: identify it from the TypeError shape, check the ancestor chain for embedded documents, and drive the fix to a certificate on the environment rather than a code workaround or a disabled-security flag.

for a principal

Own the consequence for the delivery pipeline: every environment where the product is exercised, including preview deploys and device labs, needs trustworthy origins, otherwise whole features go untested until production. Decide whether that is a public CA, an internal CA rolled to test devices, or tunnelling.

## Why the API vanished instead of failing Web platform features that expose the user's device, location, identity, or the ability to intercept a site's own network traffic are restricted to *secure contexts*. A secure context is not quite "a page served over HTTPS": it is a `Window` or a `WorkerGlobalScope` whose own origin is **potentially trustworthy** and whose entire chain of ancestor browsing contexts is potentially trustworthy as well. When the context is not secure, the browser does not attach the gated interface at all. `navigator.serviceWorker`, `navigator.clipboard`, `crypto.subtle` and `navigator.storage` are `undefined`, so the first symptom a developer sees is a language-level error such as `TypeError: Cannot read properties of undefined (reading 'writeText')`. That is a deliberate design: the feature is absent, not denied, so a feature-detection check naturally routes around it. A handful of older APIs predate the rule and keep their shape for compatibility. `navigator.geolocation` still exists on an insecure origin, but `getCurrentPosition()` invokes the error callback with a permission-denied error instead of prompting. So the same underlying rule produces two different-looking symptoms depending on the age of the API. ## What counts as potentially trustworthy The Secure Contexts specification defines the set. In practice: - Any origin whose scheme is `https:` or `wss:`. - Loopback hosts: `localhost` and `*.localhost`, the `127.0.0.0/8` IPv4 range, and `::1`. - `file:` URLs, though browsers differ on which APIs they actually enable there. - Browser-internal schemes a user agent registers as trustworthy, such as `chrome-extension:`. Just as important is what is *not* in the set. A private LAN address such as `http://192.168.1.10` or `http://10.0.0.5` is not trustworthy — it is reachable from other machines on the network, so there is a network attacker to defend against. Neither is an invented hostname like `http://staging.internal` or `http://myapp.local`, even if it only resolves inside a VPN. The browser has no way to know your intranet is friendly. ## Why localhost gets an exemption Traffic to a loopback address never leaves the machine, so no network position exists from which to tamper with it. Demanding a certificate for local development would be pure friction with no security gain, so the loopback exemption exists purely to keep the development loop cheap. The exemption is keyed on the **host**, not on "development mode". The classic trap follows directly: you test on your laptop at `http://localhost:3000`, then open the same dev server from your phone at `http://192.168.1.10:3000` and every gated feature disappears. Nothing about the code changed; the host did. ## The ancestor rule Secure-context status is inherited down the frame tree. An HTTPS document embedded in an iframe whose top-level page was loaded over plain HTTP is **not** a secure context, because an attacker who controls the outer page controls the inner one anyway. So an embedded widget can be served perfectly over HTTPS and still find its gated APIs missing — the embedder is the problem. ## Detecting it in code `window.isSecureContext` is a boolean available on both `Window` and `WorkerGlobalScope`. Use it for a clear diagnostic rather than letting a `TypeError` surface: ```js if (!window.isSecureContext) { console.warn('Insecure origin: clipboard, service workers and crypto.subtle are unavailable.'); } ``` For a specific feature, prefer detecting the feature itself, since secure context is a necessary but not sufficient condition — a browser may still lack the API, or a Permissions Policy may have disabled it: ```js if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); } else { showManualCopyFallback(text); } ``` ## Fixing the staging environment The only real fix is to make the origin trustworthy: issue a certificate for the staging hostname from a public CA or from an internal CA that the test machines already trust, and serve over HTTPS. Common shortcuts that developers reach for instead — a tunnelling service that gives you an HTTPS hostname, or forwarding the remote port to a local loopback port so the browser sees `http://localhost` — work because they change the origin the browser evaluates, not because they change the rule. What does not work is a polyfill. There is no user-land implementation of a service worker or of `crypto.subtle`'s primitives with the same guarantees, and the absence is the browser enforcing a security boundary rather than a gap in its feature set.

  • Why does geolocation behave differently from the clipboard API on an insecure origin?
    `navigator.geolocation` predates the secure-context rule, so removing the object would have broken existing pages that feature-detect it. Browsers kept the interface and moved the rejection to call time: `getCurrentPosition()` invokes the error callback with a permission-denied error. Newer APIs are simply never attached, which makes feature detection the correct check.
  • A widget you serve over HTTPS reports isSecureContext as false in one customer's site. What do you tell them?
    Secure-context status requires the whole ancestor chain to be potentially trustworthy. Their top-level page is being served over plain HTTP, so your HTTPS iframe inherits an insecure context — an attacker on the wire controls the outer page and therefore the inner one. Nothing in the widget can fix it; the embedding page has to move to HTTPS.
  • Does being in a secure context mean a gated API will work?
    No — it is necessary, not sufficient. The browser may not implement the feature at all, a Permissions Policy may have disabled it for the document, the user may have denied the corresponding permission, or the call may additionally require user activation. Always feature-detect the specific entry point and handle failure at call time.

saying these in an interview costs you the question

  • Thinks HTTPS only matters for pages handling passwords or payments
  • Believes any private or LAN IP counts as localhost
  • Proposes polyfilling navigator.clipboard or the service worker
  • Assumes an HTTPS iframe is secure regardless of the parent page
  • Blames the browser version instead of checking the URL scheme

context

open as a page

In the browser, what does navigator.permissions.query({ name: 'geolocation' }) actually tell you, what values can the resulting PermissionStatus.state hold, and what does the call deliberately not do?

level: middleimportance: should knowfreq 45%

basics

~20 s

It reads the current permission state without any user interface, resolving to a PermissionStatus whose state is 'granted', 'denied' or 'prompt'. Querying never asks the user; only the feature's own API, such as getCurrentPosition(), triggers a prompt.

open as a page

A copy button's click handler does `const text = await fetch('/doc').then(r => r.text())` and then calls `navigator.clipboard.writeText(text)`, and the write rejects with a NotAllowedError even though the user definitely clicked. What browser rule causes this, and how do you restructure the handler?

level: middleimportance: should knowfreq 42%

basics

~20 s

Clipboard writes require transient user activation — a short-lived, consumable token created by the click. Awaiting a network round trip lets it expire, so the later call is treated as unprompted. Fetch the text before the click, or hand ClipboardItem a promise.

open as a page

A page embeds a cross-origin map widget with <iframe src="https://maps.example/w" allow="geolocation">, and geolocation inside the frame still fails immediately without ever prompting the user. The same widget works when opened as a top-level page. What does Permissions-Policy have to do with it?

level: seniorimportance: should knowfreq 33%

basics

~20 s

Delegation is an intersection: a frame gets a feature only if the embedding document is itself permitted to use it and passes it down. If the top document's Permissions-Policy response header omits the widget's origin, the frame is disabled no matter what the container markup says.

open as a page

You own the frontend of a product that wants two browser permissions — notifications and geolocation. Given that a denial is stored per origin and no script can reset it, how do you decide when and how each prompt is triggered?

level: principalimportance: should knowfreq 26%

basics

~20 s

Treat each prompt as a one-shot resource. Ask only at the moment the feature is obviously useful, always behind an explicit user action, gate the real prompt behind your own in-app ask you can safely repeat, and design a working experience for users who never grant.

open as a page