Shows how an application can be secured with FIDO2 / WebAuthn: a small Windows desktop app (MFC, C++20) registers a security key for a user and signs in with it - without a browser - and explains every step in a log window. It is meant as a readable reference: each part of WebAuthn lives in its own class and can be lifted out on its own.
Built on libfido2 and the Windows WebAuthn API
(webauthn.dll). Platforms: ARM64 and x64 (Visual Studio 2022, v143, static MFC).
- What this project demonstrates
- The three roles of WebAuthn
- A registration and a sign-in, step by step
- Three ways to reach an authenticator on Windows
- What the relying party checks
- Architecture
- Credential store
- Platform differences
- Limitations of the demo
- Prerequisites and build
- Usage
- Troubleshooting
- Files
- Both WebAuthn ceremonies end to end: registration (makeCredential) and authentication (getAssertion), including the server side that is usually hidden behind a library.
- Server and client strictly separated:
RelyingParty(server) andIAuthenticatorClient(client) only exchange the data structures WebAuthn defines - exactly what would travel over the network in a real deployment. - Every server check made visible: rpIdHash, user presence / user verification flags, attestation certificate and signature, signature counter, assertion signature.
- Three ways to talk to an authenticator on Windows: libfido2 via HID, libfido2 via Windows Hello, and the Windows WebAuthn API called directly - with and without admin rights.
- Security keys only: how to keep Windows from offering Windows Hello when only a roaming authenticator (USB / NFC / BLE key) is wanted.
- Discoverable credentials (passkeys) vs. credentials that need an allow list.
- User verification on demand: touch only, or PIN required and enforced by the server.
- Persisting credentials behind an interface (
ICredentialStore), in a human readable file.
+--------------------+ options / responses +---------------------+ CTAP2 +-----------------+
| Relying Party | <------------------------------> | Client | <----------------> | Authenticator |
| (server) | WebAuthnTypes.h (JSON in a | (browser / app, | USB / NFC / BLE | (security key, |
| | real deployment) | OS WebAuthn API) | | Windows Hello)|
| RelyingParty | | IAuthenticatorClient | e.g. PhraseLock|
| ICredentialStore | | ClientData | | |
+--------------------+ +---------------------+ +-----------------+
| Role | Responsibility | In this project |
|---|---|---|
| Relying Party (RP) | Creates challenges, verifies responses, stores public keys. Never sees a private key. | RelyingParty, ICredentialStore / CredentialStore |
| Client | Builds clientDataJSON (challenge + origin), passes the request to the authenticator, returns the result unchanged. |
ClientData, LibFido2Client, WindowsWebAuthnClient |
| Authenticator | Holds the private keys, asks for touch / PIN, signs. | Your security key or Windows Hello |
FidoDemo wires the three together and narrates what happens. In this demo all roles run in one
process. In a secured application the relying party is typically the application's backend - then
only WebAuthnTypes.h crosses the network - or, for a purely local application, the application
itself.
The logs below are real runs against a security key (windows://hello, security keys only,
touch only). In the sign-in log long hex values are shortened with ….
=== Register (makeCredential) ===
device : windows://hello -> webauthn.dll directly (API v9), security keys only
user verification : discouraged (touch only)
discoverable : no
[1/5] Server: create challenge and user handle
rp.id : security.mycompany.com
user.name : jane.dow@mycompany.com
user.id : f89149ec5cff248ee2e284506981f8b0f89719f4e9702a50d795d04d8d3c8076
challenge : yqHO2QPbKCTMq96KQt0X_nIQaqUCKJ7IxZbDTEeXjjw (32 random bytes)
The server creates a random challenge and a random user handle (user.id). The user
handle is not the user name - it is an opaque identifier the authenticator stores with a
discoverable credential. A real server keeps both in the user's session.
[2/5] Client: build clientDataJSON and hash it
clientDataJSON : {"type":"webauthn.create","challenge":"yqHO2QPbKCTMq96KQt0X_nIQaqUCKJ7IxZbDTEeXjjw","origin":"https://security.mycompany.com","crossOrigin":false}
clientDataHash : 7ceb6a7b8add50f8cac401adecf6616e29a21523469e0d20c144a7404a92abeb (SHA-256)
The client wraps the challenge and the origin it is talking to into clientDataJSON. The
authenticator only ever sees its SHA-256 hash - but because it signs that hash, the response is
bound to this challenge and this origin (phishing protection).
[3/5] Authenticator: makeCredential
>>> Touch your authenticator ...
done after 4.9 s, transport: USB
The authenticator creates a new key pair for security.mycompany.com after the user touched it
(and entered the PIN if required).
[4/5] Server: parse and verify the response
clientData : type webauthn.create OK, challenge OK, origin https://security.mycompany.com OK
authData : 202 bytes
rpIdHash : f6b7c2ceec588edd0f69e2ce394a2cfa87609f111001f7c2bf135ffd9e5507f4
== SHA-256("security.mycompany.com") -> OK
flags : 0x45 UP=1 UV=1 BE=0 BS=0 AT=1 ED=0
signCount : 38
AAGUID : e86f75809198561be10b6e17443ec544
credential id : 70 bytes daf6359fc141315abfe19b4265ec03716ccfb6a31106ab1713e46b49043abbd13207f6b7c2ceec588edd0f69e2ce394a2cfa87609f111001f7c2bf135ffd9e5507f426000000
public key : ES256 (ECDSA P-256)
x = 5709cdf8ed9b1cafde204c927df4344059008cd07beefce8b8dab10f27196c3b
y = 5b8b876154287694e3193e5de89b0116817f7a4e18a319690328e1096591f33a
attestation : packed, 1 certificate(s)
[0] subject: /C=AT/ST=SZG/L=Salzburg/OU=Authenticator Attestation/O=iPoxo IT GmbH/CN=PhraseLock Attestation v1.0
issuer : /C=AT/ST=SZG/L=Salzburg/OU=R&D/O=iPoxo IT GmbH/CN=PhraseLock Attestation CA v1.0/serialNumber=ca.0003-2026.03.14
attestation sig: 71 bytes over authData || clientDataHash
verification : VALID (attestation certificate)
The response consists of authenticatorData (see AuthData.h for the byte
layout) and an attestation statement:
clientData: the server readsclientDataJSONand comparestype,challengeandoriginwith what it expects for this request - the signatures alone only prove the JSON was not altered.rpIdHashmust equal SHA-256 of the RP ID - otherwise the credential was made for another site.flags:UPuser present (touched),UVuser verified (PIN / biometrics),ATattested credential data follows,BE/BSbackup eligible / backed up (synced passkeys),EDextensions.UV=1here although only a touch was requested: Windows asked for the key's PIN anyway (see Platform differences) - the sign-in below showsUV=0.AAGUIDidentifies the authenticator model,credential idis the handle the server will send back in the allow list,public keyis what the server stores to verify future sign-ins.- The attestation proves which kind of authenticator created the key: the authenticator signs
authData || clientDataHashwith its attestation key, the certificate identifies the vendor.
[5/5] Server: store credential for 'jane.dow@mycompany.com'
saved to : C:\Users\…\AppData\Roaming\PhraseLock\PLP-FIDO-Example\credentials.txt (9 credential(s))
Registration OK.
=== Sign In (getAssertion) ===
device : windows://hello -> webauthn.dll directly (API v9), security keys only
user verification : discouraged (touch only)
[1/5] Server: create challenge
rp.id : security.mycompany.com
challenge : BoUhDcanuhymuU32f05kvAHULuFpHRDrXlZTuTPkS3U (32 random bytes)
allowList : 9 credential(s) registered for this RP
86bd8454ec34a723324026987c25…85000000 (jane.dow@mycompany.com)
931169855065c0177e53a8195cce…88000000 (jane.dow@mycompany.com)
127e2efa37008cfd398dcc3c806f…20000000 (jane.dow@mycompany.com)
ce5a16c6347e5b7939e1af85bb3d…1a000000 (jane.dow@mycompany.com)
235d58d1396c2bf0c244620b3c08…20000000 (jane.dow@mycompany.com)
53ffffc543791ef8bc66d7ceec22…23000000 (jane.dow@mycompany.com)
84712a68690fd97a3134995a84fb…26000000 (jane.dow@mycompany.com)
2742db9d7651a0e01e0f3cb0640e…23000000 (jane.dow@mycompany.com)
daf6359fc141315abfe19b4265ec…26000000 (jane.dow@mycompany.com)
[2/5] Client: build clientDataJSON and hash it
clientDataJSON : {"type":"webauthn.get","challenge":"BoUhDcanuhymuU32f05kvAHULuFpHRDrXlZTuTPkS3U","origin":"https://security.mycompany.com","crossOrigin":false}
clientDataHash : 5935e2646f364eb1242599ea581e258731dd8eb89746c25bc05b0066a6a1d6b1 (SHA-256)
[3/5] Authenticator: getAssertion
>>> Touch your authenticator ...
done after 2.2 s, 1 assertion(s) returned
The server sends a new challenge plus the allow list - the credential ids it knows for this RP (here nine test registrations of the same user). With Discoverable credential ticked the allow list is empty and the authenticator picks the account itself (passkey sign-in without a user name).
[4/5] Server: verify the assertion(s)
assertion [0]
credential id : 127e2efa37008cfd398dcc3c806f…20000000 -> 'jane.dow@mycompany.com'
user.id : c02c0bc6e51ac53797d4d8c1f8b1d252bf7815dd5034197617bf4038deea45ce
clientData : type webauthn.get OK, challenge OK, origin https://security.mycompany.com OK
authData : 37 bytes
rpIdHash : f6b7c2ceec588edd0f69e2ce394a2cfa87609f111001f7c2bf135ffd9e5507f4
== SHA-256("security.mycompany.com") -> OK
flags : 0x01 UP=1 UV=0 BE=0 BS=0 AT=0 ED=0
signCount : 42 (stored: 40) -> increased, OK
signed data : authData (37 bytes) || clientDataHash (32 bytes)
signature : ECDSA P-256 / SHA-256, 72 bytes DER 30460221009bdb321c0d330c28ae52c33548ee014a…18bdbb0ebe
verification : VALID with the public key stored at registration
[5/5] Server: user authenticated
Sign-in OK - signed in as 'jane.dow@mycompany.com'.
The authenticator answered with one of the allowed credentials - here an older one
(127e2efa…), not the one registered above; any credential in the allow list is acceptable. The
server looks up that credential id, checks clientDataJSON (a recorded old response would fail
the challenge check), rpIdHash and the flags, compares the signature counter with the stored
value (a counter that does not increase hints at a cloned authenticator) and finally verifies the
signature over authData || clientDataHash with the public key stored for that credential.
That signature is the actual proof of possession of the private key.
This authenticator uses one counter for all its credentials: 38 at the registration above, 42 at this sign-in with a different credential. The server only requires that the value increases per credential.
Both ceremonies end with a result dialog (CTaskDialog) summarising the outcome - handy for demos.
| Device in the list | Options | Client class | How it works | Admin rights |
|---|---|---|---|---|
windows://hello |
security keys only ticked (default) | WindowsWebAuthnClient |
webauthn.dll called directly with WEBAUTHN_AUTHENTICATOR_ATTACHMENT_CROSS_PLATFORM: Windows shows only the security key dialog |
not needed |
windows://hello |
security keys only unticked | LibFido2Client |
libfido2's Windows Hello backend, which forwards to webauthn.dll; Windows offers Windows Hello and security keys |
not needed |
HID device (e.g. … [1050:0407]) |
- | LibFido2Client |
libfido2 speaks CTAP2 over HID directly with the key, PIN from the PIN field | required |
Since Windows 10 1903 only elevated processes may talk to FIDO HID devices directly. Without admin
rights libfido2 does not list them at all; windows://hello covers security keys as well.
Why a direct webauthn.dll client: libfido2's Windows Hello backend always requests
WEBAUTHN_AUTHENTICATOR_ATTACHMENT_ANY and has no option to change that, so Windows would always
offer Windows Hello. WindowsWebAuthnClient hands the raw authenticatorData, attestation statement
and signature back as WebAuthnTypes, and RelyingParty verifies them with libfido2 exactly like
the responses of the other two paths.
| Check | Why | Where |
|---|---|---|
rpIdHash == SHA-256(rpId) |
Response was made for this site, not for another one | AuthData::RpIdHashMatches, RelyingParty |
clientDataJSON unchanged |
The signatures cover clientDataHash, so the JSON cannot be altered |
libfido2 (fido_cred_verify / fido_assert_verify) |
type, challenge, origin in clientDataJSON |
Right ceremony, response belongs to this request (no replay of recorded responses), made for this site (no relay from a phishing site) | CheckClientData in RelyingParty.cpp (parsed with nlohmann-json) |
UP flag |
User was present (touched the key) | libfido2 (fido_assert_set_up) |
UV flag if user verification was required |
PIN / biometrics were actually checked | RelyingParty + libfido2 (fido_*_set_uv) |
| Attestation signature | The key was created by a genuine authenticator of the stated model | fido_cred_verify / fido_cred_verify_self |
| Credential id is known | Only registered credentials can sign in | ICredentialStore::FindById |
| Signature counter increased | Hints at cloned authenticators | RelyingParty::VerifyAssertion |
| Assertion signature | Proof of possession of the private key | fido_assert_verify with the stored public key |
PLPFidoExampleDlg (MFC dialog, worker thread, result dialog)
|
FidoDemo (runs the ceremonies, narrates them in the log)
/ | \
RelyingParty ClientData IAuthenticatorClient
(server) (client) (client -> authenticator)
| / \
ICredentialStore LibFido2Client WindowsWebAuthnClient
| (libfido2) (webauthn.dll)
CredentialStore
(%APPDATA% text file)
shared: WebAuthnTypes.h (data between server and client), AuthData, Encoding, Fido2Handles.h
What to take for which purpose:
| You want to ... | Take |
|---|---|
| verify WebAuthn responses on a server (C++) | RelyingParty, AuthData, ClientData, Encoding, Fido2Handles.h, WebAuthnTypes.h, ICredentialStore.h - platform independent (libfido2 + OpenSSL) |
| store credentials somewhere else (database, REST) | implement ICredentialStore |
| call security keys from a Windows app without admin rights | WindowsWebAuthnClient (+ WebAuthnTypes.h, IAuthenticatorClient.h) |
| talk CTAP2 to a key directly | LibFido2Client |
| understand the bytes | AuthData.h (authenticatorData layout), ClientData.h |
FidoDemo and everything below it is MFC-free (UTF-8 std::string, log callback), so all
operations run on a worker thread.
Registered credentials are kept behind the ICredentialStore interface - the relying party's side
of WebAuthn. RelyingParty only knows the interface; the implementation CredentialStore writes
%APPDATA%\PhraseLock\PLP-FIDO-Example\credentials.txt, a plain text file with one
[credential] block per entry. rpId, userName, signCount and created are plain text,
all binary values (userId, credentialId, publicKey, aaguid) are hex:
[credential]
rpId = security.mycompany.com
userName = jane.dow@mycompany.com
userId = f89149ec5cff248ee2e284506981f8b0f89719f4e9702a50d795d04d8d3c8076
credentialId = daf6359fc141315abfe19b4265ec…26000000 (70 bytes)
publicKey = 5709cdf8ed9b1caf…0328e1096591f33a (ES256 x || y, 64 bytes)
aaguid = e86f75809198561be10b6e17443ec544
signCount = 38
created = 2026-09-26 10:12:47
The file is written atomically (temp file + rename) after every change and can be inspected or edited by hand. Unreadable entries are skipped with a warning on start, unknown keys are ignored. Sign In with allow list therefore also works after a restart of the app.
The same request can behave differently depending on the client, and the client is decided by the operating system, not by the browser brand:
| Platform / browser | Who talks to the authenticator |
|---|---|
Windows - every browser, and this app via windows://hello |
webauthn.dll |
| macOS - Safari | Apple's platform FIDO stack |
| macOS / Linux - Chrome | Chrome's own CTAP implementation |
| macOS / Linux - Firefox | Mozilla's own CTAP implementation |
Observed with this project:
- PIN on registration: with a PIN set on the key, Windows (and Safari) ask for the PIN when
registering, even with user verification discouraged; Chrome on macOS registered the same key
without PIN. CTAP 2.0 requires the PIN for makeCredential once one is set; CTAP 2.1 relaxes that
via the authenticator option
makeCredUvNotRqd. Sign-in without PIN works everywhere. - Other typical differences: Safari usually hides the attestation (
none),residentKey: preferredis interpreted differently, Windows silently pre-checks allow list entries before asking for a touch, extension support (prf/hmac-secret,largeBlob,credProps) varies.
Consequences: test an authenticator with at least Windows, Chrome on macOS/Linux and Safari, and let
a server rely only on what it requires - and check it (as RelyingParty does).
- No attestation trust: the attestation signature and the certificate are checked, but the certificate is not validated against a trusted root or the FIDO Metadata Service. Any self-made CA would pass.
- ES256 only (ECDSA P-256); no RS256 / EdDSA.
- No real server and no real origin: the RP runs in the same process, and the origin is
simulated as
https://<rp id>on both sides (RelyingParty::ExpectedOrigin). - No session handling: the options of Begin are passed back into Verify directly.
- The PIN field is only used for direct HID access.
-
Visual Studio 2022 with
- Desktop development with C++
- C++ MFC for latest v143 build tools (for ARM64: the ARM64/ARM64EC variant)
- vcpkg package manager (included in the C++ workload since VS 17.6)
-
One-time vcpkg MSBuild integration (Developer PowerShell for VS 2022):
vcpkg integrate install
Open PLP-FIDO-Example.sln, select ARM64 (or x64) and build.
libfido2 and its dependencies are resolved from vcpkg.json (manifest mode, pinned by
builtin-baseline) on the first build and linked statically (arm64-windows-static /
x64-windows-static). The result is a single self-contained PLP-FIDO-Example.exe.
| Library | Version |
|---|---|
| libfido2 | 1.17.0 |
| OpenSSL | 3.6.3 |
| libcbor | 0.14.0 |
| zlib | 1.3.2 |
| nlohmann-json | 3.12.0 (header only, parses clientDataJSON) |
The first build per platform takes several minutes because OpenSSL is compiled from source; later
builds (also Debug/Release switches) reuse the installed packages in vcpkg_installed\. That
folder is not part of the repository; its *\vcpkg\blds and *\vcpkg\pkgs subfolders are
intermediate files and can be deleted without triggering a rebuild.
- Device: pick an authenticator.
windows://hellouses the Windows WebAuthn API (webauthn.dll) and covers Windows Hello and USB/NFC security keys via the system dialog. HID devices only appear when the app runs as Administrator. - Device Info: prints CTAP versions, extensions, AAGUID, options (
authenticatorGetInfo). Forwindows://hellolibfido2 returns fixed placeholder values - Windows does not expose the real authenticator info; use a HID device (as Administrator) to see the key's actual data. - Register: creates an ES256 credential for RP ID / User, verifies the attestation and saves credential id + public key in the credential store.
- Sign In: requests an assertion (allow list = credentials in the store for this RP) and verifies the signature with the stored public key - exactly what a relying party does.
- Discoverable credential: on Register creates a resident key (passkey); on Sign In sends an empty allow list, so the authenticator chooses the account.
- windows://hello: security keys only (default on): Windows shows only the security key dialog (USB / NFC / BLE), no Windows Hello. Unticked, libfido2's own Windows Hello backend is used and Windows offers both.
- Require PIN (user verification): ticked =
uvrequired (PIN / biometrics), and the signature check additionally demands the UV flag - as a relying party would. Unticked = touch only (discouraged). Some keys / platforms still ask for the PIN on registration (see Platform differences).
The PIN field is only used for direct HID access with Require PIN ticked (it is disabled
otherwise). With windows://hello Windows collects PIN / biometrics in its own dialog.
| Symptom | Cause / solution |
|---|---|
Only windows://hello in the device list |
The app is not elevated - Windows hides FIDO HID devices from normal processes. Run as Administrator, or use windows://hello. If the key still does not appear as Administrator, Windows does not see it as a HID device (e.g. connected through a different channel). |
fido_dev_open … Hint: Windows blocks raw FIDO HID access … |
Same cause, see above. |
| PIN is requested although Require PIN is unticked | Windows asks for the PIN on registration if the key has one (see Platform differences). |
| App disappeared after confirming the Windows PIN dialog with Enter (older builds) | When elevated, the Enter key could reach the app's dialog as IDOK and close it. Fixed: OnOK() is ignored. |
| Sign In: No credential registered … | Register first, or tick Discoverable credential to sign in without allow list. |
Device Info shows AAGUID 000…0 |
windows://hello - libfido2 returns placeholder values there (see Usage). |
libfido2's Windows Hello backend needs the full clientDataJSON
(fido_cred_set_clientdata / fido_assert_set_clientdata), not just its hash - that is why
ClientData builds the JSON instead of only a hash.
| File | Purpose |
|---|---|
RelyingParty.h/.cpp |
Server side: challenges, verification of registration and sign-in, platform independent |
IAuthenticatorClient.h |
Client side interface: MakeCredential / GetAssertion |
LibFido2Client.h/.cpp |
Client via libfido2 (direct HID or libfido2's Windows Hello backend), device list and info |
WindowsWebAuthnClient.h/.cpp |
Client via Windows WebAuthn API (webauthn.dll), security keys only |
WebAuthnTypes.h |
Data exchanged between server and client (options, responses) |
ClientData.h/.cpp |
Builds and hashes clientDataJSON |
AuthData.h/.cpp |
Parses authenticatorData (rpIdHash, flags, signCount, AAGUID, credential id) |
ICredentialStore.h |
Interface + StoredCredential for the relying party's credential storage |
CredentialStore.h/.cpp |
ICredentialStore implementation: text file in %APPDATA% |
Encoding.h/.cpp |
Hex and base64url |
Fido2Handles.h |
RAII wrappers for libfido2 handles |
FidoDemo.h/.cpp |
Runs the ceremonies step by step and narrates them in the log |
PLPFidoExampleDlg.h/.cpp |
Dialog, runs FIDO operations on a worker thread |
PLPFidoExample.h/.cpp |
CWinApp, calls fido_init() |
res/ |
Application icon and logo (PhraseLock) |
vcpkg.json |
libfido2 dependency (vcpkg manifest) |
MIT - see LICENSE. libfido2 (BSD-2-Clause), OpenSSL (Apache-2.0), libcbor (MIT), zlib (zlib) and nlohmann-json (MIT) are fetched by vcpkg and keep their own licenses.
The PhraseLock name and logo (res/) are trademarks of iPoxo IT GmbH and are not covered by the
MIT license.