- What: A browser-based RDP client added WebAuthn support
- Impact: Enhances security for remote desktop access
Threat Research Center Insights General General How We Added WebAuthn to a Browser-Based RDP Client 8 min read Related Products Prisma Browser Prisma SASE Secure Access Service Edge (SASE) Unit 42 Incident Response By: Daniel Prizmant Published: July 2, 2026 Categories: General Insights Threat Research Tags: IDA Pro Microsoft RDP Share The Pitch TL;DR: We built the first RDP client outside of Windows to support WebAuthn redirection, beating Microsoft's own macOS, iOS and Linux clients to it (since then FreeRDP has added support too, which we’re happy to see). No browser API could do what we needed, the protocol spec was missing entire commands, and we discovered that Microsoft's Windows implementation routes through internal, undocumented code paths we had to reverse-engineer. This is the story of what we found. Prisma Browser offers support for more than the standard secure web browser. We often build support for protocols that go beyond web applications. We develop tailored solutions for Prisma Browser to enable its users to solve complicated security issues. One useful example is Remote Desktop Protocol (RDP). IT operations frequently require SSH, while remote users need to access legacy and on-premises applications using protocols such as RDP. In the past, these were only accessible via thick clients , which required opening risky network tunnels, or through remote session hops that translate traffic into browser-native protocols like HTML5. This translation often impacts performance and breaks core functionality. To meet this need, we have developed native clients directly inside the browser. Doing this has also surfaced a demand for even more flexibility and features within these native clients. This post covers one of our recent announcements that not only fills a critical gap but makes our native clients superior to the common thick applications used today. It all started when someone asked: "Can we support security keys? If a user is on a website inside the remote session and it asks for their YubiKey, can we just redirect that to their local machine?" Sure. Microsoft has a protocol for that: [MS-RDPEWA], the WebAuthn Virtual Channel Extension. There's a spec. How hard can it be? Two weeks, one IDA Pro license and several existential crises later, we had it working. Obligatory AI Section Two years ago, the reverse-engineering part of this work would have taken days, easily. Today, it took a few hours with AI to write a working IDA model context protocol (MCP) bridge, then a few more hours using that bridge to ask the binary the right questions and find the pieces we needed. AI did not reverse-engineer the feature for us. The hard parts were still knowing what to ask, validating every answer, setting breakpoints, comparing traces and proving the protocol behavior end to end. But the workflow changed dramatically: Instead of clicking through disassembly for days, we could build a tool, connect it to IDA and iterate in minutes. Reading the Spec The [MS-RDPEWA] specification describes a dynamic virtual channel (DVC) called Microsoft::Windows::RDP.Webauthn . The server sends CBOR -encoded WebAuthn requests, the client talks to a local authenticator and sends back the response. Four commands: Command ID Purpose WEB_AUTHN 5 MakeCredential or GetAssertion IUVPAA 6 "Got a platform authenticator?" CANCEL 7 Cancel current operation API_VERSION 8 Version negotiation Clean. Straightforward. Surely the hard part is just wiring it up. We'll get back to that. Note: When we did this work the spec stopped here. The [MS-RDPEWA] 3.0 revision (March 2026) has since documented two more commands, GetCredentials (9) and GetAuthenticatorList (12), gated to Windows 11 24H2+ and Server 2025+ via KB5065789. "Just call navigator.credentials" Our initial plan: receive the WebAuthn request from the server, call navigator.credentials.create() in the extension, send back the response. Here's the problem. When a user visits okta[.]com inside the remote session and triggers WebAuthn, the server intercepts the ceremony. It computes clientDataHash = SHA-256(clientDataJSON) , where clientDataJSON contains the page's origin, the challenge and the ceremony type. Then it sends that 32-byte hash over the RDP channel. On the client side, navigator.credentials.create() insists on constructing its own clientDataJSON , with its own origin: chrome-extension://... , not hxxps://okta[.]com . It hashes that, hands it to the authenticator, and the authenticator signs over it. When the assertion gets back to Okta's server: hash mismatch. Signature verification fails. SHA-256 is a one-way function. There's no going around this. We briefly considered reconstructing the clientDataJSON ourselves from the known challenge and origin. Three reasons that doesn't work: Browsers don't produce byte-identical JSON for the same inputs (field ordering, encoding). Native apps using WebAuthn SDKs add more variability. Older Windows servers don't even send the ingredients. They send only the 32-byte hash. No origin. No challenge in cleartext. Just the hash. Building a Custom Browser API No existing browser API can accept a pre-computed clientDataHash and pass it directly to an authenticator. Not navigator.credentials . Not chrome.webAuthenticationProxy (designed for the opposite direction). Not remoteDesktopClientOverride (requires the original JSON). Not WebHID (USB-only, no Touch ID, no phone-as-authenticator). Since we built this, the W3C WebAuthn working group has started standardizing exactly this case (the remoteClientDataJSON extension, editor's draft section 10.1.6). It is not yet in any shipping browser, so the custom API below is still required, but the direction is encouraging. So we built one: a custom extension API with makeCredential() and getAssertion() methods identical to navigator.credentials in every way except one. The caller supplies the clientDataHash directly; it goes straight to the authenticator. The upside of being a web-based RDP client inside Chromium: We get Chromium's entire FIDO2 stack for free. Authenticator discovery across USB, BLE, NFC and platform authenticators. Cloud-assisted Bluetooth low energy (caBLE)/Hybrid transport for phone-as-authenticator. Touch ID and Windows Hello integration. The credential selector UI. Our custom API is a thin wrapper reusing all of this machinery. Why not just use libfido2, the way FreeRDP does? Because FreeRDP is a native client and we are not. libfido2 reaches authenticators over USB or NFC by opening raw HID devices directly, which a native process can do but code inside a browser cannot. From WebAssembly there is no raw HID access. The only in-browser transport is WebHID, which is USB-only (no Touch ID, no Windows Hello, no phone-as-authenticator). Getting true device access would mean shipping a separate native helper outside the browser, defeating the point of an in-browser client. And even then, libfido2 alone would not give us platform authenticators or phone-as-authenticator over caBLE/hybrid. By wrapping Chromium's own FIDO2 stack we get all of those for free. That is also why, on Windows, our path runs into Chromium's WebAuthn machinery and its clientDataJSON requirement, while a native libfido2 client does not. This worked. YubiKey blinks. User touches it. Registration succeeds. We celebrated for approximately 90 seconds before the next problem emerged. "It Works on This Windows Version But Not That One" Our WebAuthn redirection worked beautifully against some Windows servers and completely failed against others. Same client code, same authenticator, same relying party. Here's why. On Windows, every browser (including Chromium) calls WebAuthNAuthenticatorMakeCredential from webauthn.dll . This public API unconditionally requires the full clientDataJSON : if (!pbClientDataJSON || !cbClientDataJSON || !pwszHashAlgId) return NTE_INVALID_PARAMETER; // 0x80090027 1 2 3 if ( ! pbClientDataJSON | | ! cbClientDataJSON | | ! pwszHashAlgId ) return NTE_INVALID_PARAMETER ; // 0x80090027 Our approach of passing a raw hash directly works on macOS and Linux (where we control the authenticator stack) but hits a wall on Windows. The deeper issue: Not all Windows servers send the same data. Newer Windows servers (API version 9, currently only Windows 11 25H2+) additionally transmit clientDataJSON , remoteWebOrigin and the full W3C credential options over the wire. Microsoft published the v9 struct changes to webauthn.h in September 2025. If both sides are v9, you get the new fields and everything is straightforward. But older Windows servers (Windows 10, Server 2019/2022, Windows 11 through 24H2) send only the 32-byte hash. This is the vast majority of enterprise environments. So how does Microsoft's own mstsc.exe handle older servers? It uses the same webauthn.dll . But how it actually processes a hash-only request is undocumented. Time to break out IDA Pro. Reverse Engineering mstsc.exe Before diving into disassembly, we had one paranoid question to answer first: what if the server is just... cheating? Like, what if it quietly detects that it's talking to mstsc.exe and slips it a clientDataJSON in some side channel, and the whole thing only works because Microsoft wrote both ends and decided third parties were on their own? We had to know before spending days in a disassembler. So we hooked mstscax.dll with Frida and intercepted the WebAuthn DVC channel in both directions across two complete FIDO2 ceremonies. Result: no clientDataJSON on the wire. The server sends a 32-byte clientDataHash . The client sends back a CTAP2 response. No JSON anywhere. So mstsc.exe is dealing with the exact same problem we are. But it works. Sorry I doubted you, Microsoft. mstsc.exe never calls the public WebAuthn API. webauthn.dll has a “dual personality”: Public API ( WebAuthNAuthenticatorMakeCredential , etc.): documented, stable, used by browsers and apps, requires clientDataJSON . The DVC plugin path : an exported funct