Field note 01 / Authentication

Using a YubiKey over NFC in Chrome on a Mac.

On Windows I enter my password and tap the key on my desk. On my MacBook Air, the same key lit up the same reader while Chrome waited until the request timed out.

1. Choose the hardware

I wanted to sign in to our Streetshirts operations app with the security key I already carried. It was registered on my account, it worked over USB, and our Windows desks already had readers. Moving to a Mac seemed a poor reason to change everyone’s routine.

  • The key I used was the Security Key C NFC by Yubico. It’s the cheaper, FIDO-only model with a USB-C plug and an NFC antenna. Both connections can use the same credential. A YubiKey 5 NFC also has a smart-card application called PIV, which I tried separately for unlocking my Mac. You can leave that model, its certificates and Mac account pairing off the shopping list for this tutorial. Yubico’s Security Key series
  • The reader was a Cryptnox NFC USB reader, plugged into the USB hub on my Dell monitor. The reader stays plugged in. The key stays in my pocket until the app asks for it.
  • The app already supported WebAuthn. After my password, it issued a challenge for a registered key. This walkthrough is for developers who control that sign-in flow. The final steps describe the site-specific Chrome extension and Mac helper I built for it.

2. Find the reader on the Mac

First sign in over USB in the usual way. If that fails, check which account enrolled the key before investigating NFC. Once it works, unplug the key, leave the reader connected, and run these commands in Terminal. The first installs YubiKey Manager with Homebrew.

brew install ykman
system_profiler SPSmartCardsDataType
ykman list --readers

Look for Cryptnox NFC. If it’s missing, plug the reader directly into the Mac to isolate the hub. Once it appears, rest the key on the reader and run the next two commands.

ykman --reader "Cryptnox NFC" info
ykman --reader "Cryptnox NFC" fido info

On my desk the first command identified the Security Key C NFC with FIDO2 enabled. The second read its FIDO settings over the reader. These are information commands. You can leave the key’s PIN, credentials and configuration alone.

3. Check the NFC connection independently of Chrome

macOS exposes this reader through its smart-card interface, PC/SC. Chrome’s normal security-key chooser timed out on my setup. I wanted to know whether software on the Mac could actually talk FIDO to the key over that connection. Cryptnox documents that results on macOS vary with the browser and OS version. Cryptnox platform compatibility

With Python 3 installed, open an empty working directory and run this read-only probe. Leave the key on the reader for this step.

python3 -m venv .venv
.venv/bin/python -m pip install fido2 pyscard
.venv/bin/python - <<'PY'
from smartcard.System import readers
from fido2.pcsc import CtapPcscDevice
from fido2.ctap2 import Ctap2

matches = [r for r in readers() if str(r).strip() == "Cryptnox NFC"]
if len(matches) != 1:
    raise SystemExit("Connect one Cryptnox NFC reader.")

reader = matches[0]
device = CtapPcscDevice(reader.createConnection(), str(reader))
try:
    print("Versions reported by the key:", Ctap2(device).info.versions)
finally:
    device.close()
PY

A list containing a FIDO 2 version means the key answered a CTAP2 request over NFC. If the reader is listed but opening the card fails, lift and reposition the key, then retry. At this point you have tested the reader and key. The next test needs a fresh challenge from the website. Yubico’s PC/SC interface

4. Connect Chrome to a local helper

I used a small unpacked Chrome extension and a native messaging host on my Mac. The extension recognises the key request on our own sign-in page. The helper opens the reader using the same Python interface as the probe.

The path a request takes
Chrome extension → Mac helper → Cryptnox reader → security key
  1. In a Manifest V3 extension, declare nativeMessaging and limit content scripts to the exact HTTPS origin you administer. At document_start, use a main-world script to wrap navigator.credentials.get and an isolated-world script to relay eligible requests to the extension worker. Keep the original method bound for ordinary browser requests.
  2. Only handle top-level, visible, focused Mac requests on your app’s known key prompts. Our second-factor flow supplies registered credential IDs and explicitly requests userVerification: "discouraged". Pass registration, PIN or biometric verification requests, unfamiliar options and other sites through to Chrome. Preserve the site’s verification policy.
  3. Load the extension using Developer mode → Load unpacked in chrome://extensions. Copy the extension ID. Create a host manifest called com.example.nfc.json in ~/Library/Application Support/Google/Chrome/NativeMessagingHosts/. Replace the two placeholders below with the absolute path to your executable helper and your actual extension ID.
{
  "name": "com.example.nfc",
  "description": "NFC helper for our sign-in page",
  "path": "/absolute/path/to/your/helper",
  "type": "stdio",
  "allowed_origins": ["chrome-extension://YOUR_EXTENSION_ID/"]
}

Give the helper execute permission and use an absolute interpreter path if it’s a script. In the worker, check the sender’s extension ID, frame and exact origin before opening chrome.runtime.connectNative("com.example.nfc"). Chrome starts the helper. Each message on standard input or output is UTF-8 JSON preceded by its byte length as a four-byte native-endian integer. Send diagnostic text to standard error. Chrome’s native messaging guide

5. Handle a real challenge

Configure the expected site origin and relying-party ID in the helper. Accept one outstanding assertion from the approved extension. Validate the supplied challenge and credential IDs, cap their lengths, and bind the response to that request. Our helper shows the tap prompt, waits until it has seen an empty reader, then waits for the key to arrive. Leaving a key on the desk reader cannot silently approve the next action.

Build the WebAuthn client data with type webauthn.get, the server’s base64url challenge, the pinned HTTPS origin and crossOrigin: false. Keep the exact JSON bytes you hashed. This is the signing call inside the helper, after the fresh tap and request validation.

assertion = Ctap2(device).get_assertion(
    rp_id,
    hashlib.sha256(client_data_json).digest(),
    [{"type": "public-key", "id": key_id} for key_id in allowed_ids],
    options={"up": True},
)

allowed_ids contains the decoded credential IDs for this account. Respect the authenticator’s maximum list and message sizes when sending them. Check the returned credential, relying-party hash and user-presence flag. Return the credential ID, original client data, authenticator data, signature and any user handle in the format your app already submits.

The existing server verifier still decides whether to admit the user. It must check the expected origin, fresh challenge bound to the session or action, relying-party hash, required flags and signature against the enrolled public key. The private key stays on the device. The site is trusting the installed helper to represent the origin faithfully, so restrict both ends and never offer arbitrary websites a signing endpoint. WebAuthn assertion verification

6. Test a sign-in and a second tap

  1. Start with the reader empty and the key unplugged. Refresh your site after reloading the extension. Enter your password. The app should show the tap prompt automatically. Tap and hold the key until the signed-in page opens. On my Mac there was a short wait between the reader responding and Ops admitting me.
  2. Try a protected action. Leave the key on the reader from the first test. The new challenge should wait. Lift the key and tap again. Bind this approval to the new action and consume its challenge once.
  3. Try the failure paths. An unregistered key should be refused. Cancelling, hiding the tab, or timing out should close the request and discard a late response. Unplug the reader and verify ordinary USB sign-in still works. Repeat the ordinary sign-in on a machine without the extension.

The first time this worked, the proof was mundane. I was looking at my signed-in Ops page, my normal key was still unplugged, and I hadn’t enrolled a second credential. That was the Windows desk routine I wanted back.