Skip to content

Inside CloudAP: what a third-party plug-in needs

When a user logs on with an Entra ID or a Microsoft account, the authentication package is CloudAP. CloudAP itself knows nothing about Azure: it loads plug-ins (aadcloudap.dll, MicrosoftAccountCloudAP.dll) and asks them for the identity, the token and the keys. The plug-in interface is not documented. This page describes it, from cloudAP.dll (Windows 11 26100, public symbols) and the two Microsoft plug-ins, along with a sample plug-in that logs a user on and unlocks the session. The code is on GitHub.

Documentation, not a product. CloudAP only loads plug-ins signed by Microsoft (see the last section). The sample plug-in runs in a lab VM where this check is relaxed. A normal Windows installation does not load it. Everything here is undocumented and comes from Windows 11 24H2 (26100).
How CloudAP finds its plug-ins

The same registry as the PKU2U identity providers. cloudAP!InitializePlugins enumerates IdentityStore\Providers, and for every provider other than its own two, cloudAP!LoadPlugin wants:

HKLM\SOFTWARE\Microsoft\IdentityStore\Providers\{5A0C2E62-7D4B-4C1A-9F3E-2B8D6E1A4C70}
    Name             REG_SZ        MYCP
    ApPluginDLLPath  REG_EXPAND_SZ %SystemRoot%\System32\CloudApPlugin.dll
HKLM\SOFTWARE\Microsoft\IdentityStore\LoadParameters\{5A0C2E62-7D4B-4C1A-9F3E-2B8D6E1A4C70}
    Enabled          REG_DWORD     1
  • Without Enabled, the provider is skipped with 0xC00002E9.
  • The DLL is loaded with LoadLibraryExW(path, NULL, 0x880): System32 search, and LOAD_LIBRARY_REQUIRE_SIGNED_TARGET.
  • Then GetProcAddress("CloudAPPluginInitialize"), the only export a plug-in needs.
  • After it returns, cloudAP registers the GUID with LSA as an identity provider (the LsapRegisterIdentityProvider entry of the PKU2U page). If something already registered that GUID, LoadPlugin fails with STATUS_CALLBACK_RETURNED_ALREADY_REGISTERED (0xC0000718) and unloads the plug-in. A plug-in needs its own GUID.
  • CloudAP's call-package message 0 (CloudAPReinitPlugins) unloads and loads all plug-ins again, without a reboot.
CloudAPPluginInitialize
NTSTATUS CloudAPPluginInitialize(
    ULONG Version,                                     // 1
    GUID* ProviderGuid,
    PCLOUDAP_SECPKG_FUNCTION_TABLE CloudApFunctions,   // what cloudAP gives the plug-in
    PULONG Flags,                                      // out
    PWSTR Name,                                        // out, WCHAR[21]
    PUCHAR Sid,                                        // out, SID buffer, RtlValidSid
    PVOID* Sid2,                                       // out, optional
    PVOID* Context,                                    // out, first argument of every plug-in function
    PAPPLUGIN_SECPKG_FUNCTION_TABLE PluginFunctions);  // out, a 0xB0-byte buffer to fill

This is the prototype of aadcloudap!CloudAPPluginInitialize: its public symbol is a C++ decorated name, so it carries the parameter types and the names of both tables. All the out-parameters point into the 0x188-byte record cloudAP keeps per plug-in. The last one is a trap: it is a buffer, and the plug-in copies its function table into it. Writing a pointer there instead makes cloudAP read garbage and unload the plug-in two milliseconds later. Context comes back as the first argument of every plug-in function. In Flags, cloudAP tests 0x01 (no DPAPI key sync), 0x04 (can take smart card logons from the surrogate) and 0x10 (default plug-in).

CLOUDAP_SECPKG_FUNCTION_TABLE has 15 entries, filled once by cloudAP!SpInitialize. Ten are copied from the LSA_SECPKG_FUNCTION_TABLE LSA gave cloudAP, so ntsecpkg.h already has their typedef. The five others are cloudAP functions, typed from their symbols:

#EntryTypeSource
0ImpersonateClientPLSA_IMPERSONATE_CLIENTLSA +0x58
1LsaProtectMemoryPLSA_PROTECT_MEMORYLSA +0x160
2LsaUnprotectMemoryPLSA_PROTECT_MEMORYLSA +0x168
3OpenTokenByLogonIdPLSA_OPEN_TOKEN_BY_LOGON_IDLSA +0x170
4AllocateLsaHeapPLSA_ALLOCATE_LSA_HEAPLSA +0x28
5FreeLsaHeapPLSA_FREE_LSA_HEAPLSA +0x30
6AllocateUserInfoCLOUDAP_ALLOCATE_USER_INFO*cloudAP
7FreeUserInfoCLOUDAP_FREE_USER_INFO*cloudAP
8CrediReadCredReadFn*LSA +0x148
9CrediFreeCredentialsCredFreeCredentialsFn*LSA +0x158
10CrediWriteCredWriteFn*LSA +0x198
11SignMessageWithNgcCLOUDAP_SIGN_MESSAGE_WITH_NGC*cloudAP
12GetCallInfoPLSA_GET_CALL_INFOLSA +0xC0
13GenerateKeyBindingKeyPairCLOUDAP_GENERATE_KEY_BINDING_KEY_PAIR*cloudAP
14VerifyKeyBindingKeyPairCLOUDAP_VERIFY_KEY_BINDING_KEY_PAIR*cloudAP
NTSTATUS AllocateUserInfo(ULONG Version, PSID UserSid, ULONG GroupCount, PSID* Groups,
    PCWSTR String1, PCWSTR String2, PCWSTR String3, PCWSTR String4, PCWSTR Name,
    APPLUGIN_SSO_USER_INFO*, APPLUGIN_PWD_EXPIRY_INFO*, APPLUGIN_PUBLIC_CACHED_INFO*,
    ULONG Flags, APPLUGIN_USER_INFO** UserInfo);
VOID     FreeUserInfo(APPLUGIN_USER_INFO* UserInfo);
NTSTATUS SignMessageWithNgc(PUCHAR KeyBlob, USHORT cbKeyBlob, HWND Window,
    PUCHAR Message, ULONG cbMessage, PUCHAR* Signature, PULONG cbSignature);
NTSTATUS GenerateKeyBindingKeyPair(PUCHAR* PublicKey, PULONG cbPublicKey,
    PUCHAR* PrivateKey, PULONG cbPrivateKey);
BOOL     VerifyKeyBindingKeyPair(PUCHAR PublicKey, ULONG cbPublicKey,
    PUCHAR PrivateKey, ULONG cbPrivateKey);

Two details. CredReadFn, CredWriteFn and CredFreeCredentialsFn are in ntsecpkg.h only when wincred.h is included first; otherwise those entries of the LSA table are called DummyFunction1..5. And right after the table in cloudAP's memory comes another variable, g_pLsaFunctionTable: reading a 16th entry "works" and gives the whole LSA table, but only by accident of layout. AllocateLsaHeap is entry 4.

The plug-in table: 21 functions, 2 needed

APPLUGIN_SECPKG_FUNCTION_TABLE is { ULONG Version; NoNetwork[9]; NetworkOk[12]; }: the Microsoft plug-ins build it from two static tables, the functions usable without network and the others. Names and prototypes come from the aad and MSA symbols (slot 3 is NULL in both, its name is unknown):

0Uninitialize7AssembleOpaqueData14UserProfileLoaded
1ValidateUserInfo8DisassembleOpaqueData15ConnectIdentity
2GetUnlockKey9GetToken16DisconnectIdentity
3(unnamed)10RefreshToken17RenewCertificate
4GetDefaultCredentialComplexity11GetKeys18GetCertificateFromCred
5IsConnected12LookupSIDFromIdentityName19GenericCallPkg
6AcceptPeerCertificate13LookupIdentityFromSIDName20PostLogonProcessing

GetToken is the first function a logon reaches:

NTSTATUS GetToken(PVOID Context, SECURITY_LOGON_TYPE LogonType,
    DECRYPTED_AUTH_DATA* Auth,    // { PWSTR UserLower; PWSTR User; SEC_WINNT_AUTH_PACKED_CREDENTIALS* Creds; }
    AP_BLOB* BlobIn, AP_BLOB* BlobOut,
    APPLUGIN_USER_INFO** UserInfo, SECPKG_SUPPLEMENTAL_CRED_ARRAY** Supplemental);

The plug-in checks the credential and builds the user info with callback 6. cloudAP!MakeTokenInformationV2 reads only its user SID and group list, so the identity has to be a SID LSA can resolve. The sample maps the user name to a local account, as PKU2U's built-in provider does. BlobOut is kept in cloudAP's user cache and handed back to GetKeys.

GetKeys, called right after by cloudAP!GetDPAPIKeys, returns the credential key that protects the user's DPAPI master keys:

NTSTATUS GetKeys(PVOID Context, AP_BLOB* Blob, GUID* KeyId, AP_BLOB* SavedKeyBlob, BOOL Flag,
    CREDENTIAL_KEY** Key, int* NeedNewKey, CREDENTIAL_KEY** Key2, AP_BLOB* KeyBlobOut);

typedef struct { ULONG cbTotal; ULONG Reserved; ULONG cbHeader /* 0x20 */; ULONG cbKey;
                 GUID Id; BYTE Key[]; } CREDENTIAL_KEY;          // LSA heap

When cloudAP asks for Key2, KeyBlobOut must not be empty, else the logon fails with STATUS_INTERNAL_ERROR. In the sample, the other slots are stubs that return STATUS_NOT_IMPLEMENTED, and cloudAP accepts that. A NULL slot is not the same: cloudAP skips some NULL slots, but takes a stub's error as a failure.

How a logon reaches the plug-in

cloudAP registers the plug-in by GUID, but finds it by name at logon. There are three ways in:

  1. A logon sent to the CloudAP package. The buffer is a documented SEC_WINNT_AUTH_IDENTITY_EX2 (version 0x201): User = the user, Domain = the plug-in name, PackedCredentials = a credential type GUID and its bytes (not empty). cloudAP!LsaApLogonUserEx2 compares Domain with the name each plug-in returned from CloudAPPluginInitialize and calls the one that matches. A credential provider only has to serialize this buffer with ulAuthenticationPackage = the CloudAP package id. No cloud join, no surrogate.
  2. A password logon MYCP\user in the standard password tile. That tile always sends a KERB_INTERACTIVE_UNLOCK_LOGON to Negotiate. In lsasrv!SspiExLogonUser, LsapConvertLogonAuthInfo looks the domain up among the identity providers registered with LSA, and on a match rewrites the logon into the EX2 above, which Negotiate passes to CloudAP. This is how AzureAD\user works. lsasrv uses the Name of the Providers key, cloudAP the name returned by the plug-in: they must be the same string.
  3. The surrogate, the path of Entra ID itself. cloudAP!LsaApPreLogonUserSurrogate runs on every interactive logon, but stops with SEC_E_NO_CREDENTIALS unless the machine's primary domain has a SID (joined), and then only goes on for FIDO, Windows Hello or delegation-token credentials. Not a way in for a third-party provider.

A plain LogonUser with a UPN does nothing more: cloudAP receives an empty surrogate logon (no entries) and declines.

Result in the lab

On a Windows 11 VM in a workgroup, offline, the plug-in returns a token for a local account, with authentication type CloudAP:

LsaLogonUser(CloudAP, Interactive, MYCP\User): 0x00000000
  token: PKU2U-SRV\User, auth type CloudAP
  Everyone, BUILTIN\Users, INTERACTIVE, CONSOLE LOGON, Authenticated Users,
  This Organization, Local account, S-1-5-64-36 (Cloud Account Authentication)
  • Logon screen: with the sample credential provider's tile, and with MYCP\User typed in the standard password tile. Event 4624: type 2, logon session owned by CloudAP.
  • Unlock: also a type 2 logon, Winlogon matches the SID with the locked session. No unlock-specific code in the plug-in.
  • Order of calls on a first logon: GetToken, GetKeys, slot 3, UserProfileLoaded, PostLogonProcessing. Once the user is cached, ValidateUserInfo comes first.
DPAPI: one account, two key families

DPAPI works inside a CloudAP session. But when the same local account also logs on with its password, the two kinds of session do not share their master keys: MSV protects them with a key derived from the password, CloudAP with the key from GetKeys. Each side finds the other's preferred master key unreadable and creates a new one, and a secret protected in one kind of session cannot be read in the other. Entra ID accounts (S-1-12-1-...) do not have the problem because they only ever log on through CloudAP. A third-party plug-in that maps to local accounts has to choose: its own accounts only, or a key that MSV would derive too.

The signature, and what is left
  • Microsoft signature required. LOAD_LIBRARY_REQUIRE_SIGNED_TARGET refuses an unsigned DLL and a DLL signed by your own CA (error 577), with LSA protection on or off. The interface is there and works, but on a normal machine only Microsoft's plug-ins can use it. For a product, the supported way remains an authentication package of your own plus a credential provider.
  • Lab parts in the sample. One fixed secret, a DPAPI key derived from the user SID: test VM only.
  • Not implemented in the sample: GetUnlockKey (unlock with cached keys), RefreshToken, the SID/name lookups, GenericCallPkg, and a real credential.
Need smart card or certificate logon with your own identities? Contact us.