A few months ago, I wrote all about leveraging Temporary Access Passes and Passkeys to go passwordless with Microsoft Entra here. Recently, Microsoft introduced support for attestation on iOS and Android with Microsoft Authenticator along with a few other features. They also brought in device-bound passkey support, support for FIDO2 security keys on native apps (for Android 14), and FIPS compliance for Authenticator on Android. Today, we are going to be focusing on the iOS flow for Passkeys along with attestation. We will cover:
- Overview of the Authenticator Passkey Registration Flow
- Some Features to Add to Microsoft Authenticator Passkeys on iOS
- Overview of the Authenticator Passkey Authentication Flow
- Some Features to Add to Microsoft Authenticator Passkey Login on iOS
Note: I would expect this article will be updated as I learn more about how the technology works behind the scenes. I’m actively doing packet captures, log dumps, and review of the architecture to provide as much information as I can.
Overview of the Microsoft Authenticator Passkey Registration Flow
Registering a device can be a little bit tricky as you see in my video at the end of this section. Overall, the workflow is like this:
- Navigate to the Microsoft portal of your choice and input your email address
- Tap “Use your face, fingerprint, PIN, or security key instead”
- You put in your password even if those are not allowed
- You hit next on “More information required”
- You authenticate via MFA
- You’re redirected to the Mysignins portal, which basically is saying “go add a passkey in Authenticator”
- Browse to the area in MSFT Authenticator where you create your passkey
- Authenticate with a TAP or something that isn’t a passkey and is allowed by your conditional access policies
- Face ID authentication happens
- App Attest service is invoked to validate the authenticity of your application
- Passkey is created
Video demo of all this below:
So, that means blog is all done right? No! It means, now we will look at the entire registration process. We’re not focused on most of these steps here as they’re straight forward. What, we are focusing on today around this is how App Attest works on iOS because it’s very interesting overall. Let’s talk about Apple’s App Attest service, which is the lifeblood of passkeys on Microsoft Authenticator on iOS.
Apple’s App Attest Service
The App Attest service overall is really interesting. The concept, which aligns with zero trust principles is an app can’t be trusted to check its own security. Any rooted device would falsify results and work around any of that stuff.
You leverage a shared instance of the “DCAppAttestService” class within your application to create a hardware-based, cryptographic key that leverages the Apple infrastructure to certify the key belongs to a valid instance of your application. Next, you use that key to sign server requests to assert its authenticity with your backend server.
An interesting thing to note is that for the most part app extensions can’t use this service. They provide a nice capability check, which is often coupled with logic or a less feature-rich experience when its unsupported.
Every user account on every device using MSFT Authenticator will get a unique, hardware-based crypto key when they invoke the method. The key’s identifier is provided ONCE period. Once that key is procured, it will store the associated private key in the Secure Enclave, where App Attest can use to create signatures. Also, no process can ever directly read or modify it as part of the Secure Enclave security model (stored in the Secure Enclave nonvolatile storage):

A few fun things to note about the nonvolatile storage:
- Connected to the Secure Enclave with that I2C bus to ensure it can only be accessed via the Secure Enclave.
- Devices from Fall 2020+ use a 2nd-gen Secure Storage component adding counter lockboxes storing a 128-bit salt, 128-bit passcode verifier, an 8-bit counter, and an 8-bit maximum attempt value. Access to the lockboxes is through an encrypted and authenticated protocol. For someone to get access to the keys, the Secure Enclave must derive the entropy value of the passcode and the Secure Enclave UID.
- You can only learn the user’s passcode via unlock attempts against the Secure Enclave
Before the use of keys, you leverage Apple to attest to the key’s origin on Apple hardware running a valid version of the MSFT Authenticator. Your app sends the attestation result to the backend Microsoft login servers. Within that attestation, they embed the hash of a unique, one-time challenge from the Apple servers to help protect against replay attacks. The value is created using SHA256 and should be at least 16 bytes in length. An example of the code they might use looks like this:
import CryptoKit
let challenge = <# Data from your server. #>
let hash = Data(SHA256.hash(data: challenge))
They then initiate the attestation with code like this:
service.attestKey(keyId, clientDataHash: hash) { attestation, error in
guard error == nil else { /* Handle error and return. */ }
// Send the attestation object to your server for verification.
}
Part of the reason I think this is in public preview is Apple might throttle attestation traffic, so it doesn’t get too overwhelmed. They can support up to 10 million users per day, but I would expect they are trying to be very careful.
One of the major benefits of App Attest is after the key is successfully verified, the server can require all future requests are signed by combining the one-time challenge with the server request to create a hash like this:
service.generateAssertion(keyId, clientDataHash: clientDataHash) { assertion, error in
guard error == nil else { /* Handle the error. */ }
// Send the assertion and request to your server.
}
Overall, you can see why you need the MSFT Authenticator app to register passkeys with attestation. This diagram below gives you more insight into the process I covered:

How Passkeys Are Created By 3rd Party Apps in iOS
We don’t exactly how MSFT Authenticator is creating passkeys, because overall Apple’s spec is pretty wide open on this.
The actual creation of passkeys typically works something like this on iOS:
- App gets a challenge from the backend server (the challenge provides data back that provides the authenticator owns the account)
- This code below shows a platform key registration request with the challenge, username, and user id. It also passes the request object and performs the actual request. The final aspect of this is showing the UI to prompting the user to create the passkey
let challenge: Data // Obtain this from the server.
let userID: Data // Obtain this from the server.
let platformProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: "example.com")
let platformKeyRequest = platformProvider.createCredentialRegistrationRequest(challenge: challenge, name: "Anne Johnson", userID: userID)
let authController = ASAuthorizationController(authorizationRequests: [platformKeyRequest])
authController.delegate = self
authController.presentationContextProvider = self
authController.performRequests()
My expectation is that this request is passed to Entra, along with the public key, and any other identifying information needed for the passkey. An example of what you can see at the API level. It tracks with what we know about Apple that we don’t have visibility into attestation certificates like we would with a physical key like a YubiKey:
"value": [
{
"id": "fuj64snSQdfhgfhfg",
"displayName": "Authenticator - iOS",
"createdDateTime": "2024-10-31T16:37:50Z",
"aaGuid": "90a3ccdf-635c-4729-a248-9b709135078f",
"model": "Microsoft Authenticator - iOS",
"attestationCertificates": [],
"attestationLevel": "attested"
}
]
}
In the Authentication Services frameworks with Apple, they extended the capabilities first by letting a 3rd party app be usable with Autofill with the class “ASCredentialProviderViewController” which simply is a Credential Provider extension you add to your application to let it be eligible for autofill capabilities like Passkeys.
In iOS17, a few more functions were added:
- prepareInterface (forPasskeyRegistration) which lets the “view controller” show a UI for registering passkeys.
- prepareCredentialList(for:requestParameters:) which prepares the UI to display a list of passkeys and passwords a user can select. These can also use identifiers to further filter what should be displayed based on things like domain names.
- ASPasskeyCredentialIdentity which is the object that holds that credential. One of the fun things about this one is it has a recordIdentifier attribute that lets you map items from the app’s database.
- ASPasskeyCredentialRequest is a class representing the passkey request itself. The neat thing about this is they provide the data hash. It looks like this:
convenience init(
credentialIdentity: ASPasskeyCredentialIdentity,
clientDataHash: Data,
userVerificationPreference: ASAuthorizationPublicKeyCredentialUserVerificationPreference,
supportedAlgorithms: [NSNumber]
)
- ASPasskeyCredentialRequestParameters is the last class I wanted to mention. The system creates these to handle the active passkey requests to construct the passkey credential response with the completeAssertionRequest completion handler.
Overall, it works pretty much the same way
Some Features to Add to Microsoft Authenticator Passkey Registration on iOS
The top capability that I would like to see them adapt are automatic passkey upgrades as seen below. These were introduced recently to Apple’s code and would be a huge benefit. I would personally do something like:
- User authenticates to Microsoft using Authenticator with MFA
- API call is made to see if that user has passkeys enabled
- Passkey is automatically created/upgraded to via the flow

A few other things that would be great are:
- URL schemes are introduced so if you try to login from your mobile browser that it can redirect you to the Microsoft Authenticator to start your passkey creation flow.
- After email address is entered, it should evaluate conditional access policies to see if a password field should be surfaced.
- Ability to just use push with biometrics to register a passkey without needing TAP. (Only push+password is supported today for authentication strengths)
Overview of the Microsoft Authenticator Passkey Authentication Flow for iOS
Now, let’s discuss the flow for authentication. This one has a ton of stuff going on under the covers, which we are going to discuss:
- Input your email address
- When you see “Sign in with your passkey” select “iPhone, iPad, or Android device”
- The terrifying QR code asks you to scan it and click “sign in with a passkey”
- Your device will use BLE to prove proximity.
- Your PC will say your device is now connected.
- Your iPhone will ask if you want to sign-in with your passkey and I will click “continue”
- The hybrid transport tunnel will send CTAP2 packets performing the different authentication functions of CTAP.
- I’m signed in, yay!
So, it’s simple right? Yes and no. Now, we’re going to discuss a few of the things that are going on here. First, let’s start with that scary as hell QR code nonsense.
The Client Authenticator Protocol (CTAP)
Before we talk about CTAP, let’s quickly cover WebAuthn. Well, the Web Authentication API aka WebAuthn lets you create public-key based creds for authentication. The server can invoke the API to do registration and authentication. This is how passkeys are created and authenticated with during a public key/private key exchange.
So, your question is possibly WTF is CTAP? Well, it’s simple and not simple. The client to authenticator protocol (CTAP) is a protocol used for communication between a client and the authenticator. This is a nice example of how FIDO2 leverages WebAuthn and CTAP as building blocks:

Before, we go deeper into CTAP, let’s talk about what happens on your device leveraging CTAP itself.
First, you get this Windows pop-up facilitating the connection between your device and your iOS device:

That leads you to scanning a cute little QR code like this one:

We will discuss later the decoding of the QR code overall.
I captured some logs on the device to see more about the stuff it’s doing. A few fun things to share, until we table discussing BLE for a few minutes:
## This is the bluetoothd FIDO process which allows users to authenticate to U2F-enabled services and applications##
Line 2915: default 14:13:34.370165-0400 SpringBoard [FBSystemService][0x23f7] Received request to open "com.apple.AuthenticationServicesUI" with url "FIDO:<private>" from lsd:123 on behalf of Camera:10606.
Line 7092: default 14:13:35.340787-0400 bluetoothd FIDO updated: <> -> <9f 0c ac c6 23 ae b2 70 f3 1a df c2 d3 04 86 c5 44 b7 b5 15>, rate Default -> Default
Line 7094: default 14:13:35.341136-0400 bluetoothd FIDO advertise start: <9f 0c ac c6 23 ae b2 70 f3 1a df c2 d3 04 86 c5 44 b7 b5 15>, rate 270 ms
Line 22277: default 14:13:44.455744-0400 bluetoothd FIDO updated: <9f 0c ac c6 23 ae b2 70 f3 1a df c2 d3 04 86 c5 44 b7 b5 15> -> <>, rate Default -> Default
Line 22285: default 14:13:44.456891-0400 bluetoothd FIDO advertise stop
##The Auth Kit Daemon performing the Attest##
Line 10723: default 14:13:37.375406-0400 akd [0xb12703840] activating connection: mach=false listener=false peer=true name=com.apple.ak.anisette.xpc.peer[207].0xb12703840
Line 10724: default 14:13:37.390578-0400 akd BEGIN [5159]: SignAndAttestation enableTelemetry=YES
Line 10725: default 14:13:37.392762-0400 akd [0xb1260fb00] activating connection: mach=true listener=false peer=false name=com.apple.adid
Line 10729: default 14:13:37.447510-0400 akd [0xb1260fb00] invalidated after the last release of the connection object
Line 10731: default 14:13:37.447848-0400 akd [0xb1260cb00] activating connection: mach=true listener=false peer=false name=com.apple.adid
Line 10733: default 14:13:37.464895-0400 akd [0xb1260cb00] invalidated after the last release of the connection object
Line 10735: default 14:13:37.469580-0400 akd END [5159] 0.075767s:SignAndAttestation
Search "attest" (4 hits in 1 file of 1 searched) [Normal]
Now, if we look deeper at CTAP (the current version is 2.2), we can learn a little bit about how it works.
CTAP has 3 parts to it:
- Authenticator API
- Message Encoding (responsible for CBOR encoding that I mentioned earlier)
- Transport-specific Binding (can support NFC, USB, Bluetooth). Apple uses Bluetooth for proximity and hybrid transport for the CTAP2 messages. Some of the neat stuff about it you should be aware of:
- Must use Bluetooth 4.0+
- Client and authenticator create and use a long-term link key (LTK) and encrypt all communications
- From the logs below, you can see the LTK and Apple is using Security Mode 1, Level 2 (unauthenticated pairing with encryption)
- Overall there’s a lot more, and you can read more here, but I wanted to hit on the stuff I found interesting to know about.
- Hybrid Transport
##LTK Stuff from the Device Logs##
efault 14:13:35.876513-0400 bluetoothd Link key requested for device <mask.hash: 'pv/VDyvdPE3FqtaTP296RQ=='>
default 14:13:35.876538-0400 bluetoothd updateADVBufferConfig current:03 new:03 configChanged:00
default 14:13:35.876565-0400 bluetoothd Found link key for device <mask.hash: 'pv/VDyvdPE3FqtaTP296RQ=='>
default 14:13:35.876610-0400 bluetoothd Deferred security enforcement with linkKeyExists 1, remoteSupportsSSP 1, bothTwoDotOne 1, seclevel 0
default 14:13:35.876751-0400 bluetoothd Enforcement complete with OI_OK
default 14:13:35.877094-0400 bluetoothd Trace finishEnforcementEffort handle 0x02630002 : PMAN_ST_POWER_ENFORCE --------------------> PMAN_ST_IDLE
CTAP Authenticator API
The authenticator API performs all sorts of different functions that power your passkey journey. The different commands it supports are:
- authenticatorMakeCredential (0x01): requests generating a new credential in the authenticator
- authenticatorGetAssertion (0x02): mentioned earlier
- authenticatorGetNextAssertion (0x08): this is called when the get assertion contains more than one. The logic is neat:
- Checks the relying party matches the request
- Remembers the response member
- Adds cred user info to the credentialInfo list
- Helps display the credentialInfo list
- Lets user select which credential they want
- Returns the one the user selected
- discards all others
- authenticatorGetInfo (0x04): makes the authenticator report a list of supported protocol versions, extensions, AAGUID of the device, and capabilities.
- authenticatorClientPIN (0x06) sends the PIN in encrypted format when setting or changing the PIN.
- authenticatorReset (0x07) is the command used to reset the authenticator back to factory settings.
QR-Initiated Transactions
When the Microsoft Authenticator wants to talk to Microsoft, it displays that lovely QR code. Interestingly, the MSFT Authenticator will use a CBOR parser, but I’m not MSFT authenticator, so let’s take a look!
So, I decoded the QR code, and it looked like this: FIDO:/445852099506341501817288898933548110488919379779961580898331489734466299278596755138158373301294194277597106261344801090083714783796284504187096627151618107096654083332
That scheme is for a base10 encoded CBOR map with keys that map to certain values. That’s a data format for very small code without a need for version negotiation or so the IETF tells me. What matters is the decoding of it. I decoded it like this:
import cbor, math; uri='FIDO:/445852099506341501817288898933548110488919379779961580898331489734466299278596755138158373301294194277597106261344801090083714783796284504187096627151618107096654083332'; ns=uri.split('/')[-1]; bs=b''.join(int(n, 10).to_bytes(int(math.log2(10**len(n))//8), 'little') for n in (ns[ii: ii+17] for ii in range(0, len(ns), 17))); print(cbor.loads(bs))
That printed out this:
{
0: b'\x03f\x9e\xb9\x7f\xc0p%\x90@y\xa1\xb5\xd9\x9dC%\x86\x01\xe8=e`\xce\x95\x13\x02\xe7\x9a\xcb\xdfe\xde',
1: b'5b\xcf\xed\xd4\x0f\x84\x7f"\xd4]\x9a\x81\xaba\xf9',
2: 2,
3: 1730469902, (Unix Time)
4: True, (Supports Device Linking)
5: 'ga' request_type -> CableRequestType::kMakeCredential (Get Assertion)
}
Thanks to Stack Overflow, I learned a few fun things:
- 0 is the compressed public key for the Microsoft Back End
- 1 is the shared secret with which the device will prove it read the QR code
- 2 is the number or registered tunnel server domains
- 3 is the creation time in UNIX time
- 4 is that the QR code supports device linking
- 5 is for “Get Assertion”
So, this taught me that scanning this QR Code is connecting the device to my computer (via device linking) for the purposes of the “Get Assertion” method which is used to request crypto proof of user authentication and user consent of it, using my device-bound passkey and the subsequent public key that Entra has.
The BLE advertisement is also interesting. The BT transport uses the advertisement as proof of proximity. Within the advertisement, is a UUID that must match between the client and candidate device. The key is derived from a parent key to ensure separation within the domain with code like this below. The key uses SHA-256 and the info value is a 32-bit, little-endian, purpose identifier:
type keyPurpose uint32
const (
keyPurposeEIDKey keyPurpose = 1
keyPurposeTunnelID keyPurpose = 2
keyPurposePSK keyPurpose = 3
)
func derive(output, secret, salt []byte, purpose keyPurpose) {
if uint32(purpose) >= 0x100 {
panic("unsupported purpose")
}
var purpose32 [4]byte
purpose32[0] = byte(purpose)
h := hkdf.New(sha256.New, secret, salt, purpose32[:])
if n, err := h.Read(output); err != nil || n != len(output) {
panic("HKDF error")
}
}
The key that is used to decrypt the advertisement is a 64-bit value derived from the QR secret with keyPurposeEIDKey. That code looks like this:
func awaitQRAdvert() [16]byte {
var eidKey [32 + 32]byte
derive(eidKey[:], qrSecret[:], nil, keyPurposeEIDKey)
return awaitAdvert(eidKey)
}
That decryption leverages that EID key as a pair of 256-bit keys. The first 32 bytes are an AES key and the second 32 bytes are a HMAC-SHA256 key. The candidate BLE advert is valid if the last four bytes are a valid HMAC tag of the other 16 bytes. For each valid BLE advertisement, the initial 16 bytes are treated like an AES block and decrypted with the AES key.
The reason why this is interesting is what is inside of the encrypted data:
- A flags byte, which will likely be for versioning in the future.
- 80-bit connection nonce (shows that the proximity check passed)
- 24-bit routing ID
- 16-bit tunnel service identifier (translated to a valid domain for the service as discussed in the next section)
The functions that decrypt the advertisement help lead it to what tunnel server it will use:
func reservedBitsAreZero(plaintext [16]byte) bool {
return plaintext[0] == 0
}
func unpackDecryptedAdvert(plaintext [16]byte) (
nonce [10]byte,
routingID [3]byte,
encodedTunnelServerDomain uint16) {
copy(nonce[:], plaintext[1:])
copy(routingID[:], plaintext[11:])
encodedTunnelServerDomain = uint16(plaintext[14]) | (uint16(plaintext[15]) << 8)
return
}
Hybrid Transport
I was questioning if they’re actually using CTAP 2.1 or CTAP 2.2. One of the significant changes in CTAP 2.2 is the introduction of “Hybrid Transport”
Hybrid Transport decouples the proximity validation between the device and the computer with the transmission of CTAP2 messages. The network communication is handled by a service called the “tunnel service.”
The tunnel service is a highly available service with a domain name that the authenticators know. The tunnel service as shown by the code below is “cable.auth.com” or “cable.ua5v.com.”
var assignedTunnelServerDomains = []string{"cable.ua5v.com", "cable.auth.com"}
func decodeTunnelServerDomain(encoded uint16) (string, bool) {
if encoded < 256 {
if int(encoded) >= len(assignedTunnelServerDomains) {
return "", false
}
return assignedTunnelServerDomains[encoded], true
}
shaInput := []byte{
0x63, 0x61, 0x42, 0x4c, 0x45, 0x76, 0x32, 0x20,
0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x20, 0x73,
0x65, 0x72, 0x76, 0x65, 0x72, 0x20, 0x64, 0x6f,
0x6d, 0x61, 0x69, 0x6e,
}
shaInput = append(shaInput, byte(encoded), byte(encoded>>8), 0)
digest := sha256.Sum256(shaInput)
v := binary.LittleEndian.Uint64(digest[:8])
tldIndex := uint(v & 3)
v >>= 2
ret := "cable."
const base32Chars = "abcdefghijklmnopqrstuvwxyz234567"
for v != 0 {
ret += string(base32Chars[v&31])
v >>= 5
}
tlds := []string{".com", ".org", ".net", ".info"}
ret += tlds[tldIndex&3]
return ret, true
}
The interesting question is how does it pick a domain??
If the encoded value equals 0, it uses cable.ua5v.com, whereas if its 1 then it uses cable.auth.com. Those come from the plain text in the decrypted advertisement.
It leverages the tunnel ID and WebSockets to make the connection to the tunnel. The URI is composed, which is the domain/cable/connect/+lower-case, hex-encoded routing ID/+lower-case, hex-encoded tunnel ID. The subprotocol identifier is set to fido.cable.
Feel free to read more about the hybrid transport here, as it’s a relatively complicated service that provides caching and optimized traffic management overall.
I wasn’t sure it was really being used, but I was able to prove it by reviewing a packet capture:

Overall, the gist is by abandoning Bluetooth for the transfer of data, and committing to leveraging WebSockets and the tunnel provided by hybrid transport it makes cross-device authentication possible.
Some Features to Add to Microsoft Authenticator Passkey Login on iOS
The main feature I would like to see added is passkey attestation for enterprise. There is a configuration that you can leverage that requires that passkey creation can only be done on managed devices. Additionally, it will allow you to use your own CA certificates to sign the passkeys, which would really elevate the situation. Sure, it’s slightly different than hardware-based attestation. I think it’s very similar to the concepts around BYOK in Office 365.

In addition, I do think you should be able to support more than Bluetooth (for companies that are uncomfortable with that) and would prefer USB HID or NFC as those are part of the spec. That is probably unrealistic, but I would like to see it overall.
Final Thoughts
Overall, I’m really excited about the roadmap for Passkeys at Microsoft along with the direction/progress made as of late. I think they can definitely build on top of what they’re already doing to create something special by GA time.
There’s more work to be done and they should lean into some of the great capabilities Apple (and Google) have introduced to bring a complete solution that makes all of their customers happy.

4 thoughts on “Deep Dive into Microsoft Authenticator Passkeys for iOS”
Hello,
You mentioned you’d be doing packet captures and more investigation. I’d be very interested to know what URL’s Microsoft Authenticator on iOS reaches out to during the Passkey registration and authorization process other than cable.ua5v.com (Google) or cable.auth.com (Apple). I have not been able to find this documented anywhere.
Those are the only ones involved in hybrid transport.
Other URLs are like Microsoft analytics but that’s it
Thanks for the write-up!
I’m still working my way through but one small note; “principles” and “principals” are different words; e.g. I think your meant principles here: https://mobile-jon.com/2024/11/01/deep-dive-into-microsoft-authenticator-passkeys-for-ios/#:~:text=with%20zero%20trust-,principals,-is%20an%20app
Thanks fixed it. Missed on my spellcheck.