Web NFC Permission Denied Troubleshooting Guide
This guide is part of Web NFC: Reading and Writing Tags in the Browser, which sits within the Native Device Integration Patterns reference.
Problem Statement
Permission Denied errors during NFC tag interaction are among the most common production blockers for Android PWAs. Chromium raises two distinct DOMException types — SecurityError and NotAllowedError — and each has a different root cause and fix path. SecurityError means the browser rejected the hardware request before any prompt was shown, almost always because the origin is not a secure context. NotAllowedError means the prompt was either denied by the user, skipped because the call happened outside a transient user gesture, or interrupted because the tab lost focus. Getting the two apart early is what prevents wasted debugging time chasing the wrong fix.
Feature Detection Gate
Run this check synchronously before any NDEFReader call. It separates a configuration problem (insecure origin, wrong browser) from a true permission failure and gives you a clean error message for each case.
export function assertNfcEnvironment() {
if (!window.isSecureContext) {
throw new DOMException(
'Web NFC requires a secure context (HTTPS or localhost)',
'SecurityError'
);
}
if (!('NDEFReader' in window)) {
throw new DOMException(
'NDEFReader is not supported in this browser engine',
'NotSupportedError'
);
}
}
If assertNfcEnvironment() throws SecurityError, fix the origin before touching anything else. If it throws NotSupportedError, route users to a QR code fallback or manual input — no amount of retry logic will make NFC work in an unsupported environment.
Solution Walkthrough
Step 1 — Validate secure context and API availability
Call assertNfcEnvironment() (defined above) at the top of any function that touches NDEFReader. Do not gate only on 'NDEFReader' in window; a non-secure origin passes that check but still raises SecurityError the moment scan() is awaited.
Step 2 — Enforce user activation
Chromium rejects scan() calls that originate outside a transient user gesture. Wire your NDEFReader initialisation directly to a click or touchstart handler. Calls triggered by setTimeout, Promise.then, or framework lifecycle hooks like onMounted that run asynchronously after the gesture are outside the activation window.
export async function startNfcScan(onReading) {
assertNfcEnvironment();
// Must be called synchronously from inside the event handler
const reader = new NDEFReader();
const controller = new AbortController();
reader.addEventListener('reading', ({ message }) => {
onReading(message);
}, { signal: controller.signal });
await reader.scan({ signal: controller.signal });
return controller;
}
// Wire to a button — never call startNfcScan() outside this handler
document.getElementById('scan-btn').addEventListener('click', async () => {
try {
const controller = await startNfcScan(handleNdefMessage);
// Store controller.abort() somewhere to stop scanning later
} catch (err) {
displayNfcError(err);
}
});
Step 3 — Use AbortController for SPA lifecycle safety
Single-page apps that mount and unmount components during navigation frequently orphan NDEFReader instances. This leaves stale reading event listeners registered and puts the browser’s permission state out of sync with component state — a common source of NotAllowedError on the second scan attempt. Bind the AbortController to the component’s cleanup phase.
import { useEffect, useRef } from 'react';
export function useNfcReader(onReading) {
const controllerRef = useRef(null);
useEffect(() => {
if (!window.isSecureContext || !('NDEFReader' in window)) return;
// Initialisation must be triggered by a user gesture upstream;
// this hook only manages the lifecycle, not the gesture binding
controllerRef.current = new AbortController();
const { signal } = controllerRef.current;
const reader = new NDEFReader();
reader.addEventListener('reading', ({ message }) => {
onReading?.(message);
}, { signal });
return () => {
controllerRef.current?.abort();
};
}, [onReading]);
}
Aborting the controller removes the reading listener and stops any active scan started with that signal. NDEFReader has no cancel() method; the AbortController pattern is the only standards-compliant way to stop a scan mid-flight.
Step 4 — Retry with exponential backoff
Transient NotAllowedError states occur during mobile browser wake-up, when the OS temporarily suspends hardware access for background tabs. Immediate re-prompts after these transient failures trigger browser-level permission throttling, which prevents the prompt from appearing at all. Use backoff instead.
export async function initNfcWithRetry(maxAttempts = 3) {
assertNfcEnvironment();
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const reader = new NDEFReader();
await reader.scan();
return reader;
} catch (err) {
const isTransient = err.name === 'NotAllowedError' && attempt < maxAttempts;
if (!isTransient) throw err;
await new Promise(resolve => setTimeout(resolve, 800 * attempt));
}
}
}
For persistent NotAllowedError — where the user has explicitly tapped Block — backoff will not help. Detect this by checking whether the error persists beyond the first attempt, then surface a UI prompt explaining that permission must be re-enabled in browser settings. This follows the same pattern used in handling permission denials gracefully across other device APIs.
Failure Modes and Recovery
| Error name | User-visible symptom | Cause | Minimal fix |
|---|---|---|---|
SecurityError |
Scan button never triggers a prompt | Origin served over HTTP, or WebView missing android.permission.NFC |
Enforce HTTPS; add <uses-permission android:name="android.permission.NFC"/> to host app manifest |
NotAllowedError (gesture) |
Scan silently fails; no prompt appears | scan() called outside a transient user gesture |
Move scan() synchronously inside click or touchstart handler |
NotAllowedError (denied) |
Prompt shown once, then never again | User tapped Block; browser stored the decision | Show settings-redirect UI; do not loop re-prompts |
NotAllowedError (transient) |
Scan fails only after screen lock or app switch | OS suspended NFC hardware for background tab | Listen to visibilitychange; re-initialise when visibilityState === 'visible' |
NotSupportedError |
Scan button unavailable | Chrome flags disabled, wrong browser, or desktop Chrome | Hide NFC controls; offer QR code or manual input |
The visibility-state pattern for backgrounding deserves a code note. Mobile OSes do not restore NDEFReader state automatically. When the tab returns to foreground, the previous reader instance is no longer valid; you need a fresh one inside a user gesture handler, or an auto-reinitialisation triggered by visibilitychange if your use case permits a background-re-entry scan.
export function watchVisibilityForNfc(restartScan) {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
restartScan();
}
});
}
For high-throughput environments where many tags pass within range rapidly, add a debounce flag to the reading handler — the event queue can flood the main thread if onReading does any synchronous DOM work.
Browser and Platform Caveats
Web NFC (NDEFReader) is supported only in Chrome for Android (version 89+). Chrome for desktop, Firefox, and Safari on any platform do not implement the API. iOS Safari has no timeline for support; devices on iOS must be routed immediately to QR code generation or SMS-based sharing covered in the SMS and email fallback architectures guide. Android WebView adds a second layer of requirements beyond the browser permission model: the host app must declare <uses-permission android:name="android.permission.NFC"/> in AndroidManifest.xml and must have android:hardwareAccelerated="true" set on the relevant Activity. Without either, the permission prompt is silently intercepted by the host OS before Chromium can surface it, making the failure look identical to a user-denied NotAllowedError and very difficult to distinguish in the field.
FAQ
Why does Web NFC throw SecurityError even on localhost?
Standard Chrome for Android treats localhost as a secure context. If you see SecurityError while testing on localhost, verify the exact origin with location.origin in DevTools. The most common hidden cause is that your dev server proxies to http:// on a named IP address rather than the literal string localhost, or that you are testing inside an Android WebView that does not inherit the browser’s secure-context rules.
How do I handle permission denial when the user taps Block?
Catch NotAllowedError, display an in-page overlay explaining why NFC access is needed, and provide a Try again button that calls NDEFReader.scan() inside a fresh click handler. Do not auto-retry silently. Once a user explicitly blocks the permission, the browser persists that decision; only a manual reset in browser or OS settings will allow a new prompt.
Can I bypass the permission prompt for enterprise PWAs?
No. Web NFC strictly enforces user activation and an explicit permission prompt. Enterprise Android deployments that need silent NFC access must use Android Enterprise managed-device policies or a fully native WebView wrapper — both fall outside standard web APIs.
Why does NFC scanning stop after backgrounding the PWA?
Mobile OSes suspend hardware access for background tabs. When document.visibilityState returns to 'visible', the previous NDEFReader instance is no longer valid. Create a new instance and call scan() again; do not attempt to reuse or restart the old one.
Related
- Web NFC: Reading and Writing Tags in the Browser — the parent guide covering the full
NDEFReaderAPI, NDEF payload parsing, and write workflows - Handling Permission Denials Gracefully — broader patterns for recovering from any device-API denial, including exponential backoff for re-prompts
- QR Code Generation for Cross-Device Sharing — the recommended fallback path when NFC is unavailable on iOS or desktop
- Native Device Integration Patterns — the top-level reference for Web NFC, Clipboard API, File System Access, and async payload formatting