,

There’s no built-in policy for Entra VM login

Azure ships a built-in policy to disable local accounts on your VMs. It ships none to enable the Entra login that makes disabling them survivable. Here’s how the pieces actually fit — and where “compliant” quietly stops meaning “signed in.”

It started with an extension rollout

Rolling extensions out across a fleet is routine work. You decide which ones belong on every machine — the monitoring agent, the anti-malware handler, whatever your baseline says — and you push them with Azure Policy so nobody has to remember. Configure it once, and every VM anyone creates from that day on arrives with them.

Microsoft Entra login belongs on that list. Not because it’s another agent, but because of what it lets you take away: local administrator accounts.

That’s the real goal. Local admins are the accounts nobody rotates, that sit in a shared vault entry, that outlive both the person who created them and the ticket that justified them. With the Entra login extension on the machine, people sign in over RDP or SSH with their Entra credentials instead. Access comes from Azure RBAC. MFA and device state come from Conditional Access. All of it gone the moment someone leaves the directory — no local account left behind to forget about.

So I went to add it to the rollout, the same way as everything else.

There is no built-in policy for it.

What made me look twice is what is there. Azure ships Configure Windows Server to disable local users (357cbd2d-b5c0-4c73-b40c-6bd84f06ce09) and its Linux counterpart (cd22fc48-f2c9-4b86-98d3-ec1268b46a8a). Those don’t enable Entra login. They assume it’s already running, then take the local accounts away.

That’s the second half of the job, shipped without the first. Disabling local users before Entra login works is a lockout with a timer on it.

To be fair to Microsoft, this isn’t neglect. The Community-Policy repo is full of DeployIfNotExists policies for other extensions — Log Analytics, Qualys, IaaSAntimalware. The pattern is well understood. The Entra login extensions just never got the same treatment. So I wrote them, and in doing so learned more about how this feature actually works than any doc gave me.

What “just install the extension” actually involves

The extensions are AADLoginForWindows and AADSSHLoginForLinux, both from publisher Microsoft.Azure.ActiveDirectory. On paper it’s a one-liner: drop the extension on the VM, done.

It isn’t, because the extension needs a system-assigned managed identity on the VM to do its job. Without one, it doesn’t warn — it fails. On Linux you get exit code 22. On Windows, terminal error 1007, which decodes to DSREG_E_MSI_TENANTID_UNAVAILABLE — the handler tries to read the tenant from the instance metadata service, finds no identity, and gives up.

So this is never one policy. It’s a chain: something adds the identity, then something installs the extension. A Modify policy for the first step, a DeployIfNotExists for the second.

Here’s the part worth internalising: you don’t have to build the identity step yourself. Microsoft already maintains two built-in policies that add a system-assigned managed identity to a VM:

  • 3cf2ab00-13f1-4d0c-8971-2ac904541a7e — for VMs with no identity at all
  • 497dff13-db2a-4c0f-8603-28fa3b331ab6 — for VMs that already carry a user-assigned identity

They’re named for Guest Configuration, because that’s the scenario they were written for. But a system-assigned identity is a system-assigned identity — the one they add is exactly what the Entra login extension needs. Pair the two extension policies with these two built-ins and you have the whole chain, without reinventing the identity step or its permissions.

That split is deliberate, and it taught me something. There are two built-ins because the Modify operation differs: a VM with nothing gets SystemAssigned, a VM that already has a user-assigned identity gets SystemAssigned, UserAssigned — you have to preserve the existing one, not clobber it. Getting that wrong detaches a managed identity that something else probably depends on.

Order matters, and the policy encodes it

My two extension policies match only VMs that already have a system-assigned managed identity:

{
  "field": "identity.type",
  "contains": "SystemAssigned"
}

That’s not an oversight — it’s the design. If the extension policy targeted a VM without an identity, it would deploy an extension that can only fail, forever, and report the VM as non-compliant with no way to fix itself. By matching only VMs that already have the identity, the ordering is baked into the model: remediate the identity policies first, the extension policies second.

For existing VMs you run the remediations in that order by hand. For new VMs it’s automatic — Modify applies at creation time, and both extension policies use evaluationDelay: AfterProvisioningSuccess so they only evaluate once the VM has finished provisioning and the identity is attached.

One more filter earns its place on the Windows side: Server Core is excluded (notContains: "core"). The extension needs the Desktop Experience — installing it on Core is another guaranteed failure. Small detail, but it’s the difference between a policy that helps and one that manufactures red in your compliance dashboard.

Where “compliant” stops meaning “working”

This is the part I didn’t expect, and it’s the most important thing in this post.

A DeployIfNotExists policy decides compliance with an existence condition. Mine checks that the extension exists and reports success:

{
  "field": "Microsoft.Compute/virtualMachines/extensions/provisioningState",
  "equals": "Succeeded"
}

That’s the strongest signal a policy can read. A policy cannot see inside the extension’s own handler status. And here’s the catch: AADLoginForWindows can report provisioningState: Succeeded, exit code 0, “Plugin completed successfully” — while the Microsoft Entra join itself failed.

I reproduced this without trying. I built a Windows VM, it joined, I tore the resource group down, I built another VM with the same name. The extension went green. The machine was not joined. Buried in C:\WindowsAzure\Logs\Plugins\...\CommandExecution.log was the real story: HTTP 400, error_hostname_duplicate, “Another object with the same value for property hostnames already exists.”

The cause is a sharp edge in Entra itself: device objects outlive the VMs they came from. Deleting the resource group does not delete the Entra device. Rebuild a VM under a name that was used before, and the join collides with the leftover device — while Azure Policy still shows a reassuring green tick.

To be clear, this is not a flaw in the policy. A manual extension install behaves identically; the handler swallows the join failure and reports success either way. But it means a compliant dashboard is not proof that anyone can actually sign in. Where that distinction matters, verify out of band:

dsregcmd /status   # look for: AzureAdJoined : YES

Invoke-AzVMRunCommand runs that for you without needing RDP. The Linux extension, incidentally, doesn’t have this failure mode — AADSSHLoginForLinux doesn’t create a device object, and its failures surface as an actually-failed provisioning state.

The extension doesn’t let anyone in

Worth stating plainly, because it trips people up: installing the extension grants nobody access. It makes Entra login possible; it authorises no one.

Sign-in needs an Azure role — Virtual Machine Administrator Login or Virtual Machine User Login, assigned on the VM or a scope above it. And no, Owner and Contributor don’t count. That separation is intentional: the people who can manage a VM are not automatically the people who can log into it. If you want MFA or device checks on top, that’s Conditional Access applied to the Azure Windows VM Sign-In and Azure Linux VM Sign-In applications, configured separately again.

So the full picture is four moving parts: identity, extension, RBAC role, Conditional Access. The two policies here cover the second. The built-ins cover the first. The other two are yours to assign.

Why MFA works in production but broke my test

I wanted to actually sign in, not just confirm the join. Linux was trivial — az ssh vm dropped me onto a shell as my Entra user, no SSH key anywhere on the box. Windows fought me, and the fight was the best lesson of the whole exercise.

From my Mac, RDP kept failing. First with 0x3107 — “the remote machine is AAD joined, disable Network Level Authentication.” I disabled NLA. Then the password was rejected. Then, once the password was accepted, a flat wall: “The sign-in method you’re trying to use isn’t allowed.”

That last one is the interesting failure. The password was accepted — the block was MFA. My tenant enforces it, and a classic password RDP session has no way to carry a second factor. One channel, one factor, done.

Which raises the obvious question: production environments have MFA on and people sign in to Entra VMs every day. How?

Because production clients carry the MFA over a channel that can. Two of them:

  • From an Entra-joined Windows client. When you sign into your work laptop in the morning — with Windows Hello, with MFA — that device gets a Primary Refresh Token, and the PRT already contains an MFA claim. RDP to an Entra VM from that machine inherits the claim. No second prompt. The MFA happened hours ago.
  • Web sign-in. The RDP client opens an actual Entra browser login, and that can do the push, the TOTP, the FIDO key.

My Mac was doing neither. It had fallen back to a single-factor password prompt at the VM console. The fix was to force the web sign-in path, which the macOS Windows App only triggers when the .rdp file carries both enablerdsaadauth:i:1 and targetisaadjoined:i:1 — the second flag was the missing piece. Add it, make the connection name match the Entra device name, and the browser login appeared, MFA and all. Desktop.

The unifying idea is the same reason az ssh vm “just works” on Linux: it does a browser az login, which carries MFA natively. Whenever Entra VM sign-in feels broken, ask which channel the client is using — not whether MFA is “on.”

What I shipped

Two policy definitions, contributed to the Azure/Community-Policy repo as pull request #541: one for the Windows extension, one for Linux. Each states its managed-identity prerequisite in its own description, follows the documented image support matrix, and exposes a matchAll…Images switch for custom and golden images.

Both definitions in full, plus the deployment steps and the built-in identity policies to pair them with, are in this gist — whatever the pull request ends up doing, you can use them today.

I deliberately did not wrap them in an initiative that also provisions the identity and encodes the ordering. That would take too much for granted for a shared repo — every other extension policy in there is a standalone DeployIfNotExists, and none of them pre-builds a managed identity for you. The prerequisite belongs to whoever assigns the policy, combined with the two built-ins above. Keeping the contribution to two focused policies matches how the repo already works, and leaves the operational choices — scope, ordering, permissions — where they belong: with the operator.

If you want the tightest possible permissions, one note on that. The two built-in identity policies request Contributor on the assignment scope, because adding a system-assigned identity to a VM that already has a user-assigned one makes ARM revalidate the link and needs Microsoft.ManagedIdentity/userAssignedIdentities/assign/actionVirtual Machine Contributor alone returns Forbidden there. If broad Contributor is unacceptable in your environment, swap the built-ins for a custom Modify that requests Virtual Machine Contributor plus Managed Identity Operator instead.

What I’d still like to nail down

I tested the Linux sign-in end to end, and the Windows join and interactive RDP login including MFA. What I haven’t validated is the RDP path from a properly Entra-joined Windows client — the PRT route — because I don’t have one in that tenant. I’d expect it to be smoother than the web-sign-in dance I went through, but I haven’t watched it happen.

And the compliant-versus-joined gap still bothers me. There’s no existence condition that closes it, because the signal a policy can read genuinely stops at the extension boundary. If you’re running Entra VM login at scale, treat compliance as necessary, not sufficient, and check AzureAdJoined where it counts. If someone knows a cleaner way to surface the join state to Policy, I’d genuinely like to hear it.

I write about Azure identity and security at simonvedder.com. Building something similar, or think I got a detail wrong? Drop me a line.