Windows non-admin: detect FIDO2 keys via access-denied signal and link to Windows security-key settings
Background
On Windows, raw FIDO HID access is reserved for elevated processes (since Win10
1903, the OS routes non-elevated apps through its own WebAuthn API). keyroost
already notes this — the Security Keys pane needs "Run as administrator." But
when running non-admin there are two usability problems:
-
FIDO-only keys disappear entirely. The FIDO2 capability is detected from
the HID usage page 0xF1D0, which a non-elevated process cannot read —
opening that HID collection returns ERROR_ACCESS_DENIED (5). So a FIDO-only
key resolves to no device at all and vanishes from the sidebar, with no
explanation. (Multiprotocol keys still appear via their PC/SC applets, just
without the FIDO2 tab.)
-
No guidance toward what does work non-admin. Windows' own Settings page
(Sign-in options → Security Key → Manage) can change the PIN, manage
biometrics, and reset the key without admin, because Settings is the
privileged component. Users have no in-app pointer to it.
What I found (hardware-tested: Token2 PIN+ and FIDO-only keys, such as Yubico Security Key with no CCID interface)
Enumerating HID interfaces non-admin, the FIDO interface is the one — and
essentially the only one — that returns ERROR_ACCESS_DENIED. Its VID/PID are
still readable from the device path string (no device open needed). So "an
access-denied HID interface" is a reliable non-admin signal that a FIDO key is
present, even though we can't read its 0xF1D0 usage page. This is exactly how
Windows itself gates the interface.
The webauthn.dll API was also investigated and is a dead end for external-key
management: WebAuthNGetPlatformCredentialList / WebAuthNDeletePlatformCredential
only return platform (Windows Hello / TPM) credentials, and there is no PIN or
reset function in the API at all (verified against the full header). So the
realistic non-admin scope is: detect + inform + link to Windows settings.
Sample non-admin HID enumeration (Token2 multiprotocol key)
dev 1: CreateFileW failed (err 5) path-VID:349E PID:0026 <-- protected (FIDO?)
dev 3: VID:349E PID:0026 usage_page:0x0001 # same key, non-FIDO HID interface
...
enumerated 25 HID interface(s), opened 24, found 1 FIDO
dev 1 is the gated FIDO collection (err 5); dev 3 is the same key's
keyboard/HID interface, openable but reporting generic usage page 0x0001.
Proposed workaround
A small Windows-only helper that, without elevation:
- Detects FIDO keys by enumerating HID interfaces and flagging any that return
ERROR_ACCESS_DENIED (5) (FIDO signature) or report usage page 0xF1D0
(readable when not gated), pulling VID/PID from the device path.
- Opens Windows' security-key page via
ms-settings:signinoptions-launchsecuritykeyenrollment (with a
ms-settings:signinoptions fallback).
In the GUI: on Windows, when a FIDO key is detected but no working FIDO access
exists, show the FIDO2 tab with an "Administrator rights needed" card explaining
the situation and offering an "Open Windows security-key settings" button.
For FIDO-only keys (which otherwise don't resolve into a device), synthesize a
minimal device entry from the detected VID/PID so the tab can appear.
Everything is #[cfg(windows)] and inert elsewhere; the helper contains the only
unsafe (FFI), confined to its own crate.
Implementation
Detection signal (the heart of the workaround)
For each enumerated HID interface, if CreateFileW fails with error 5, treat it
as a FIDO key:
if handle.is_null() || handle as isize == INVALID_HANDLE_VALUE as isize {
let err = GetLastError();
// ERROR_ACCESS_DENIED (5) on a HID interface is the signature of a
// Windows-protected FIDO collection: we cannot open it non-elevated,
// which is exactly the FIDO-HID gating. Treat it as a detected FIDO key,
// using VID/PID parsed from the device-path string (no open needed).
if err == 5 {
push_unique(&mut out, FidoKeyInfo {
product: None,
vendor_id: path_vid,
product_id: path_pid,
});
}
continue;
}
VID/PID from the path string (no device open)
fn parse_vid_pid(path_lc: &str) -> (Option<u16>, Option<u16>) {
fn grab(s: &str, key: &str) -> Option<u16> {
let i = s.find(key)? + key.len();
let hex: String = s[i..].chars().take(4).collect();
u16::from_str_radix(&hex, 16).ok()
}
(grab(path_lc, "vid_"), grab(path_lc, "pid_"))
}
Open Windows' security-key settings
pub(crate) fn open_windows_security_key_settings() -> Result<()> {
const PRIMARY: &str = "ms-settings:signinoptions-launchsecuritykeyenrollment";
const FALLBACK: &str = "ms-settings:signinoptions";
let open = to_wide("open");
for uri in [PRIMARY, FALLBACK] {
let uri_w = to_wide(uri);
let h = unsafe {
ShellExecuteW(std::ptr::null_mut(), open.as_ptr(), uri_w.as_ptr(),
std::ptr::null(), std::ptr::null(), SW_SHOWNORMAL)
};
if (h as isize) > 32 { return Ok(()); } // >32 == success
}
Err(WinWebAuthnError::LaunchFailed)
}
GUI tab (non-admin Windows, FIDO key detected)
Administrator rights needed
A security key is connected, but managing its FIDO2 settings (PIN, passkeys,
reset, fingerprints) in this app requires administrator rights on Windows.
You can change the PIN, manage biometrics or reset the key without admin
rights using Windows' built-in security-key settings, or restart this app as
administrator for full management here.
with a single button: Open Windows security-key settings →
open_windows_security_key_settings().
The code above is tested and confirmed. GUI produces a tab below:
Caveats
-
Access-denied detection is a heuristic. FIDO is the HID class Windows
protects this way, so on a normal machine it's reliable, but in principle
another protected HID device could match. It detects "a protected HID interface
is present, probably FIDO" rather than "definitely FIDO." For zero false
positives, combine it with a VID/PID allowlist of known FIDO vendors.
-
Open regression in the GUI wiring. Integrating the detection call into the
device-scan path emptied the sidebar entirely (all keys vanished, including
PC/SC ones). It is currently gated behind KEYROOST_WIN_FIDO=1 to isolate it;
the root cause is not yet found. The standalone helper and probe work
(hardware-confirmed); the in-GUI device synthesis is not yet stable.
Windows non-admin: detect FIDO2 keys via access-denied signal and link to Windows security-key settings
Background
On Windows, raw FIDO HID access is reserved for elevated processes (since Win10
1903, the OS routes non-elevated apps through its own WebAuthn API). keyroost
already notes this — the Security Keys pane needs "Run as administrator." But
when running non-admin there are two usability problems:
FIDO-only keys disappear entirely. The FIDO2 capability is detected from
the HID usage page
0xF1D0, which a non-elevated process cannot read —opening that HID collection returns
ERROR_ACCESS_DENIED (5). So a FIDO-onlykey resolves to no device at all and vanishes from the sidebar, with no
explanation. (Multiprotocol keys still appear via their PC/SC applets, just
without the FIDO2 tab.)
No guidance toward what does work non-admin. Windows' own Settings page
(Sign-in options → Security Key → Manage) can change the PIN, manage
biometrics, and reset the key without admin, because Settings is the
privileged component. Users have no in-app pointer to it.
What I found (hardware-tested: Token2 PIN+ and FIDO-only keys, such as Yubico Security Key with no CCID interface)
Enumerating HID interfaces non-admin, the FIDO interface is the one — and
essentially the only one — that returns
ERROR_ACCESS_DENIED. Its VID/PID arestill readable from the device path string (no device open needed). So "an
access-denied HID interface" is a reliable non-admin signal that a FIDO key is
present, even though we can't read its
0xF1D0usage page. This is exactly howWindows itself gates the interface.
The
webauthn.dllAPI was also investigated and is a dead end for external-keymanagement:
WebAuthNGetPlatformCredentialList/WebAuthNDeletePlatformCredentialonly return platform (Windows Hello / TPM) credentials, and there is no PIN or
reset function in the API at all (verified against the full header). So the
realistic non-admin scope is: detect + inform + link to Windows settings.
Sample non-admin HID enumeration (Token2 multiprotocol key)
dev 1is the gated FIDO collection (err 5);dev 3is the same key'skeyboard/HID interface, openable but reporting generic usage page
0x0001.Proposed workaround
A small Windows-only helper that, without elevation:
ERROR_ACCESS_DENIED (5)(FIDO signature) or report usage page0xF1D0(readable when not gated), pulling VID/PID from the device path.
ms-settings:signinoptions-launchsecuritykeyenrollment(with ams-settings:signinoptionsfallback).In the GUI: on Windows, when a FIDO key is detected but no working FIDO access
exists, show the FIDO2 tab with an "Administrator rights needed" card explaining
the situation and offering an "Open Windows security-key settings" button.
For FIDO-only keys (which otherwise don't resolve into a device), synthesize a
minimal device entry from the detected VID/PID so the tab can appear.
Everything is
#[cfg(windows)]and inert elsewhere; the helper contains the onlyunsafe(FFI), confined to its own crate.Implementation
Detection signal (the heart of the workaround)
For each enumerated HID interface, if
CreateFileWfails with error 5, treat itas a FIDO key:
VID/PID from the path string (no device open)
Open Windows' security-key settings
GUI tab (non-admin Windows, FIDO key detected)
with a single button: Open Windows security-key settings →
open_windows_security_key_settings().The code above is tested and confirmed. GUI produces a tab below:
Caveats
Access-denied detection is a heuristic. FIDO is the HID class Windows
protects this way, so on a normal machine it's reliable, but in principle
another protected HID device could match. It detects "a protected HID interface
is present, probably FIDO" rather than "definitely FIDO." For zero false
positives, combine it with a VID/PID allowlist of known FIDO vendors.
Open regression in the GUI wiring. Integrating the detection call into the
device-scan path emptied the sidebar entirely (all keys vanished, including
PC/SC ones). It is currently gated behind
KEYROOST_WIN_FIDO=1to isolate it;the root cause is not yet found. The standalone helper and probe work
(hardware-confirmed); the in-GUI device synthesis is not yet stable.