A web-based tool for managing FIDO2 PINs on YubiKey devices — including first-time PIN setup, PIN change, and factory reset — without needing the YubiKey Manager desktop application.
┌─────────────────────┐ postMessage ┌──────────────────────────┐
│ Web UI │ ─────────────────────▶│ Chrome Extension │
│ (React / Vite) │ │ (MV3, content script / │
│ localhost:5173 │ ◀─────────────────────│ background service │
└─────────────────────┘ response │ worker) │
└──────────┬───────────────┘
│ chrome.runtime
│ sendNativeMessage
▼
┌──────────────────────────┐
│ Native Host │
│ pinreset_host.py │
│ (Python, stdio) │
└──────────┬───────────────┘
│ fido2 / PC/SC
▼
┌──────────────────────────┐
│ YubiKey │
│ (USB HID / NFC) │
└──────────────────────────┘
Why a browser extension is required: Browsers cannot access USB HID or FIDO devices directly from a web page due to security sandboxing. The Chrome Extension acts as a trusted bridge, using the Native Messaging API to communicate with a locally-installed Python process that has the OS-level privileges to reach the YubiKey.
| Requirement | Version | Notes |
|---|---|---|
| macOS, Linux, or Windows | macOS 12+ / Ubuntu 20.04+ / Windows 10+ | |
| Python | 3.8+ | Must be on PATH |
| pip / pip3 | Any recent version | Used to install fido2 |
| Chrome or Chromium | Any recent version | Firefox is not supported |
| YubiKey | Series 5 or later | Must support FIDO2 |
| PC/SC reader (NFC) | Optional | Required for NFC-only operations |
The native host is a Python script that Chrome's Native Messaging API calls as a subprocess. It must be registered in a specific location so Chrome can find it.
macOS / Linux:
cd /path/to/PinWebReset
chmod +x install/install.sh
./install/install.shWindows (run in Command Prompt as your normal user — not Administrator):
cd C:\path\to\PinWebReset
install\install.batThe installer:
- Verifies Python 3.8+ is available
- Installs the
fido2library via pip - Writes
com.yubico.pinreset.jsonto the correct native messaging host directory for your OS and browser - On Windows, also creates a
.batshim (Chrome cannot invoke Python directly on Windows) and writes the required registry key
If you already know your extension ID (see step 2), pass it to skip the placeholder step:
./install/install.sh --extension-id abcdefghijklmnopqrstuvwxyz123456- Open Chrome and navigate to
chrome://extensions - Enable Developer mode using the toggle in the top-right corner
- Click Load unpacked
- Select the
extension/directory from this repository - The extension appears in the list with a generated ID — copy that ID (32 lowercase letters)
The extension icon may appear in the Chrome toolbar. If it does not, click the puzzle-piece icon and pin it.
The native messaging manifest restricts which extensions are allowed to call the native host. You must replace the placeholder with your real extension ID.
macOS / Linux:
./install/update-extension-id.sh abcdefghijklmnopqrstuvwxyz123456Windows — edit the manifest file directly:
- Open
%APPDATA%\Google\Chrome\NativeMessagingHosts\com.yubico.pinreset.jsonin a text editor - Replace
EXTENSION_ID_PLACEHOLDERwith your extension ID - Save the file
Restart Chrome after updating the manifest.
cd web-ui
npm install # first time only
npm run devOpen http://localhost:5173 in Chrome. The page communicates with the extension via postMessage. Keep the dev server running while using the tool.
| Operation | Description | Requirements |
|---|---|---|
| Set PIN | Configure a PIN for the first time | YubiKey with no FIDO2 PIN set |
| Change PIN | Update the existing FIDO2 PIN | Current PIN required |
| Factory Reset | Erase all FIDO2 credentials and remove PIN | Physical touch held for ~5 seconds |
PIN rules:
- Minimum 4 characters (YubiKey default; some configurations require 6+)
- Maximum 63 bytes (UTF-8 encoded)
- Unicode is supported — the PIN is hashed before transmission to the device
Retry counter: The YubiKey tracks failed PIN attempts. After 8 consecutive failures the PIN is blocked and a factory reset is required to recover.
NFC-connected YubiKeys are accessed via PC/SC, not FIDO HID. Requirements:
- A PC/SC-compatible NFC reader (e.g. ACS ACR1252, HID Omnikey)
- PC/SC daemon running (
pcscdon Linux/macOS) - The
fido2Python library must be installed with PC/SC support (installed automatically)
macOS: PC/SC support is built in. No additional configuration needed.
Linux: Install the PC/SC daemon and CCID driver:
sudo apt install pcscd libccid # Debian / Ubuntu
sudo systemctl enable --now pcscdWindows: PC/SC is built into Windows. Ensure the "Smart Card" service is running.
By default, Linux restricts access to USB HID devices to root. Add a udev rule to allow your user group to access YubiKeys without sudo:
Create /etc/udev/rules.d/70-yubikey.rules:
# YubiKey HID access for plugdev group
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1050", GROUP="plugdev", MODE="0660"
SUBSYSTEM=="usb", ATTRS{idVendor}=="1050", GROUP="plugdev", MODE="0660"
Apply the rules:
sudo udevadm control --reload-rules
sudo udevadm triggerAdd yourself to the plugdev group (re-login required):
sudo usermod -aG plugdev "$USER"Verify group membership after re-login:
groups | grep plugdev- The native host is a local binary. Review
native-host/pinreset_host.pybefore running it. It communicates only via stdin/stdout to Chrome; it does not open any network ports. - No network transmission. The PIN is sent from the web UI to the extension via
postMessage, then from the extension to the native host via the Chrome Native Messaging API (a local IPC mechanism). Nothing leaves the machine. - Extension permissions are minimal. The extension requests only the permissions needed to communicate with the native host and the web UI origin.
- The
allowed_originsfield in the manifest restricts native messaging access to your specific extension ID. Do not share a manifest file that contains a real extension ID with untrusted parties. - Factory reset is irreversible. All FIDO2 credentials stored on the YubiKey will be deleted. Hardware-backed passkeys registered with websites will need to be re-enrolled.
- Make sure the web UI is running at
http://localhost:5173(the extension checks this origin) - Confirm the extension is loaded and enabled at
chrome://extensions - Hard-reload the web UI page with Cmd/Ctrl+Shift+R
- Check that
install.sh(orinstall.bat) completed successfully - Verify the manifest file exists and contains the correct extension ID:
- macOS:
~/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.yubico.pinreset.json - Linux:
~/.config/google-chrome/NativeMessagingHosts/com.yubico.pinreset.json - Windows:
%APPDATA%\Google\Chrome\NativeMessagingHosts\com.yubico.pinreset.json
- macOS:
- Run the host script manually to check for Python errors:
python3 /path/to/native-host/pinreset_host.py # Then type a newline; you should see a JSON error, not a crash - Check Chrome's native messaging log:
chrome://extensions→ your extension → "Inspect views: service worker" → Console
- Unplug and re-insert the YubiKey
- Check the YubiKey is recognised by the OS:
- macOS:
system_profiler SPUSBDataType | grep -A5 YubiKey - Linux:
lsusb | grep Yubico - Windows: Device Manager → Human Interface Devices
- macOS:
- On Linux, confirm your user is in the
plugdevgroup and udev rules are applied (see above)
Add the udev rules described in the Linux: udev Rules section above.
The YubiKey entered a locked state after too many wrong PIN attempts. A factory reset is the only recovery:
- Use the Factory Reset operation in this tool (requires a 5-second touch), or
- Use YubiKey Manager CLI:
ykman fido reset
All FIDO2 credentials will be erased.
ModuleNotFoundError: No module named 'fido2'
Install it for the Python interpreter that runs the script:
python3 -m pip install fido2
# or, if pip3 is separate:
pip3 install fido2On Windows, make sure you're installing into the same Python that install.bat found.
Run install.bat as your normal user account, not as Administrator. The key is written to HKCU (current user), which never requires elevation.
PinWebReset/
├── extension/ # Chrome MV3 extension
│ ├── manifest.json
│ ├── background.js # Service worker — native messaging bridge
│ ├── content.js # Injected into the web UI page
│ └── icons/
├── native-host/
│ └── pinreset_host.py # Python native messaging host
├── web-ui/ # React + Vite front-end
│ ├── src/
│ ├── index.html
│ ├── vite.config.js
│ └── package.json
├── install/
│ ├── install.sh # macOS / Linux installer
│ ├── install.bat # Windows installer
│ └── update-extension-id.sh
└── README.md
# Terminal 1 — web UI dev server
cd web-ui
npm install
npm run dev
# Listening on http://localhost:5173
# Terminal 2 — (no separate process needed for native host or extension;
# Chrome manages the native host lifecycle)Load the extension from extension/ as an unpacked extension (see step 2 above). The extension reloads automatically when you click the refresh icon on chrome://extensions.
The native host uses the Chrome native messaging wire format: a 4-byte little-endian length prefix followed by a UTF-8 JSON payload.
A minimal test with Python:
import json, struct, subprocess, sys
HOST = "/path/to/native-host/pinreset_host.py"
proc = subprocess.Popen(
[sys.executable, HOST],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
)
msg = json.dumps({"action": "list_devices"}).encode()
proc.stdin.write(struct.pack("<I", len(msg)) + msg)
proc.stdin.flush()
length = struct.unpack("<I", proc.stdout.read(4))[0]
response = json.loads(proc.stdout.read(length))
print(response)
proc.terminate()cd web-ui
npm run build
# Output in web-ui/dist/ — serve with any static file server