Skip to main content

Azure Innovators

Retro comic illustration of two identical brass keys jammed into the same keyhole of one padlock at opposing angles, neither able to turn
Both modules bring a valid copy of the same dependency. The session loads one.

Where We Left Off

Part 1 was the audit: a disabled account still holding Global Administrator, a sync scope that handed out cloud identities by luck. Part 2 was the build: five narrow permissions replacing one master key, with the verification pass that proved the old paths were closed.

Both posts end tidily. Real systems don't.

What follows is four weeks of actually using that script, and the four failures that showed up in that time. Not one of them was a flaw in the least-privilege design. They were the ordinary friction of a script that talks to on-prem Active Directory, Azure Resource Manager, and Microsoft Graph in a single PowerShell session, on a workstation that has opinions of its own.

One of them I made worse before I made it better. That's the one worth your time.

Failure 1: The Default Subscription From Another Tenant

A help desk tech ran the script with an explicit tenant and subscription on the command line, and Azure sign-in failed:

WARNING: The access token is from the wrong issuer
'https://sts.windows.net/<tenant-A>/'. It must match the tenant
'https://sts.windows.net/<tenant-B>/' associated with this subscription.

The script was passing -TenantId correctly. The problem was a setting on the workstation. Az PowerShell has a persisted config value, DefaultSubscriptionForLogin, and this machine had one pointing at a subscription in a different tenant — left over from other consulting work.

Connect-AzAccount -TenantId <tenant> authenticated against the right tenant, then tried to activate a subscription that lived somewhere else. Check yours:

Get-AzConfig

If DefaultSubscriptionForLogin has a value you didn't deliberately set, clear it:

Clear-AzConfig -DefaultSubscriptionForLogin -Scope CurrentUser

The script-side fix is to stop relying on the default at all. When you know both values, pass both, and the setting never gets a vote:

$connect = @{}
if ($TenantId)       { $connect.TenantId     = $TenantId }
if ($SubscriptionId) { $connect.Subscription = $SubscriptionId }
Connect-AzAccount @connect

This one cost twenty minutes. The next one cost considerably more.

Failure 2: Method Not Found

Cutaway of a lock cylinder with one brass key seated in its single keyway and an identical key lying against the outside of the lock body
One version of an assembly per name, per load context. The second key isn't wrong, it's just late.

With the subscription sorted, the same run died a few lines later:

Connect-MgGraph: Entry point was not found.

Then, on a later attempt, a more specific version of the same thing:

Method not found: 'Azure.Identity.AuthenticationRecord
Azure.Identity.InteractiveBrowserCredential.Authenticate(
Azure.Core.TokenRequestContext, System.Threading.CancellationToken)'.

The maddening part was that it looked intermittent. Most runs were fine. Then one wasn't.

Why it looked random

The script checks for an existing Azure context before signing in. If a tech had used Az earlier that day, the context was cached, Connect-AzAccount never ran, and everything worked. The failure only appeared on a genuinely cold start — a fresh session with no cached Azure context, where the Azure sign-in actually executed before the Graph sign-in.

That's the tell. This was never random; it was ordering.

What's actually colliding

Microsoft Graph PowerShell and Az PowerShell both depend on Azure.Identity and Azure.Core, and they ship different versions of each. You can see exactly which, on your own machine:

$mods = "$HOME\Documents\PowerShell\Modules"
$asm  = [System.Reflection.AssemblyName]
foreach ($m in 'Microsoft.Graph.Authentication','Az.Accounts') {
    Get-ChildItem (Join-Path $mods $m) -Recurse -Filter 'Azure.Identity.dll' |
        ForEach-Object {
            "{0,-32} {1}" -f $m, $asm::GetAssemblyName($_.FullName).Version
        }
}

On the machine in question:

ModuleAzure.IdentityAzure.Core
Microsoft.Graph.Authentication 2.39.01.18.0.01.51.1.0
Az.Accounts 5.5.21.21.0.01.57.0.0

Neither module isolates these into a private load context. Both load them into the default one, and .NET will only load one version of an assembly per name there. Whichever module gets there first wins, and the other one binds against a version it wasn't compiled for. Connect-MgGraph was compiled expecting a method signature that exists in 1.18 and asks for it from 1.21.

Attempt one: import the module first

If load order decides it, load Graph first. So the script imported Microsoft.Graph.Authentication before touching anything Az.

It changed nothing. Measuring the loaded assemblies showed why:

Import-Module Microsoft.Graph.Authentication
[AppDomain]::CurrentDomain.GetAssemblies() |
    Where-Object { $_.GetName().Name -like 'Azure.*' } |
    Select-Object { $_.GetName().Name }, { $_.GetName().Version }

Import-Module loads Azure.Core only. Azure.Identity — the one that matters — isn't loaded until the first cmdlet that actually needs it, which is Connect-MgGraph itself. By then Az had long since won.

Importing a module is not the same as loading its dependencies.

Attempt two: the fix that crashed PowerShell

So force them. Find Graph's own copies and load them explicitly, before Az appears. Don't run this one — it's here because the failure it causes is instructive:

$base = (Get-Module Microsoft.Graph.Authentication).ModuleBase
foreach ($dll in 'Azure.Core.dll','Azure.Identity.dll') {
    $path = Get-ChildItem $base -Recurse -Filter $dll | Select-Object -First 1
    [System.Reflection.Assembly]::LoadFrom($path.FullName)
}

I tested it. The method that had been missing now resolved:

[Azure.Identity.InteractiveBrowserCredential].GetMethod(
    'Authenticate',
    [Type[]]@([Azure.Core.TokenRequestContext],
              [System.Threading.CancellationToken]))

It returned the method. Green light.

The next real run killed the process:

Fatal error.
0xC0000005
[process exited with code 3221225477]

0xC0000005 is an access violation. Not a PowerShell error, not something you can catch — the process is simply gone.

Here's what I'd missed. Interactive sign-in on Windows goes through the Web Account Manager broker, which is native code. My reflection test exercised the managed type system, where two assembly versions coexist perfectly happily. It never went near the native boundary, where two copies of Azure.Identity wrapping the same broker do not coexist at all.

The test passed and the fix was wrong, because the test never touched the code path that was failing. I had turned a clean, catchable error message into a process crash, and I'd done it with a green check mark in hand.

A claw hammer raised above a brass key already seated in a padlock, with a hairline crack starting in the lock casing
Forcing the assemblies into place turned a catchable error into a process crash.

The fix: ordering, not force

Back to the measurements, which contained the answer all along. The relationship between these two modules is asymmetric:

  • Load Graph first, then Az: Az loads cleanly. It side-loads its own Azure.Core 1.57 alongside Graph’s 1.51 and carries on.
  • Load Az first, then Graph: Graph fails.

Az tolerates arriving second. Graph does not. So the fix isn’t to fight the loader — it’s to connect Graph before anything touches Az:

Connect-MgGraph -TenantId $TenantId -Scopes $scopes -NoWelcome   # first
$context = Get-AzContext                                         # then Az

In the script this meant moving the Graph connection ahead of the Azure context check, which needed one small change: the tenant has to be known up front rather than read from the Az context. Since the tenant is already a parameter, that was a parameter check rather than a redesign.

Verify it the same way the series verifies everything — make the failing case fail. In a fresh session, deliberately put Az first. This should break:

Get-AzContext
Connect-MgGraph -TenantId $tenant -Scopes 'User.Read.All' -NoWelcome

If that still succeeds on your machine, your two modules happen to agree on Azure.Identity today. They won’t forever.

Failure 3: The Retry Logic From Part 2 Was Half Right

Part 2 described a replication race: a freshly synced user appears in Get-MgUser slightly before the authentication-methods endpoint catches up, and Graph reports that two different ways, resourceNotFound and accessDenied. The fix there was to treat both as transient and retry.

That fix was correct for new accounts and wrong for everything else.

Weeks later, a tech ran a password reset on an employee who’d existed for a month. The script spent a minute retrying, announcing each time that Entra ID hadn’t finished replicating the user, then gave up. The user had been in Entra ID since the previous month. Nothing was replicating.

accessDenied is ambiguous, and the account’s age resolves it:

  • On an account created seconds ago, it’s the authorization context still replicating. Retry.
  • On an account that already existed, there is nothing left to replicate. It means the caller genuinely lacks the role — almost always a PIM activation that expired, since activations last hours and the technician’s last onboarding was days ago.

Retrying the second case wastes a minute and prints a misleading explanation. So the retry now depends on whether this run created the account:

$notFound = $msg -match 'resourceNotFound|could not be found'
$denied   = $msg -match 'accessDenied|Request Authorization failed'
$transient = $notFound -or ($denied -and $AccountJustCreated)

if ($attempt -lt 5 -and $transient) {
    Start-Sleep -Seconds 15
}
elseif ($denied -and -not $AccountJustCreated) {
    # Nothing was replicating. Say what's actually wrong.
    Write-Warning 'Activate your PIM role, run Disconnect-MgGraph, retry.'
}

The lesson isn’t about TAPs. An error message that’s right 80% of the time is a bug, because the 20% sends someone to investigate a system that was never broken. If you can tell the two cases apart in code, tell them apart in the message.

Failure 4: Modules That Can Evaporate

One more, quieter than the others. While investigating the assembly conflict, the module paths looked like this:

C:\Users\<user>\OneDrive - <org>\Documents\PowerShell\Modules\...

Documents is redirected to OneDrive, so every installed PowerShell module syncs to the cloud. With Files On-Demand, those DLLs are placeholders that can be evicted to free up disk space. A module that loads today can be a stub next month, and an assembly that fails to materialize produces exactly the kind of baffling error we’d just spent a day on.

They happened to be hydrated for me, so this wasn’t the culprit. It’s a trap waiting its turn. Check:

Get-ChildItem "$HOME\Documents\PowerShell\Modules" -Recurse -Filter *.dll |
    Where-Object { $_.Attributes -match 'Offline' } |
    Select-Object -First 10 FullName

Anything returned is cloud-only right now. Pin the tree so OneDrive can’t reclaim it:

attrib +P /S /D "$HOME\Documents\PowerShell\Modules\*"

Re-pin after installing or updating modules; new files arrive unpinned. The better long-term answer is to keep modules out of a synced folder entirely.

While you’re there, look for version sprawl. Four side-by-side Az.Accounts versions and two complete Microsoft.Graph sets is how mismatched pairs end up in the same session:

Get-Module Az.Accounts, Microsoft.Graph.Authentication -ListAvailable |
    Select-Object Name, Version, ModuleBase
A navy-gloved hand sliding one brass key into a door lock while a second brass key waits on a hook beside it
Connect Microsoft Graph first, then Az. Order is the entire fix.

What This Cost, and What It Taught

Four failures, none of them in the least-privilege design. The design was fine. What broke was the plumbing underneath it: a leftover config value, two Microsoft modules disagreeing about a shared dependency, an error message that was confidently wrong, and a sync client that treats DLLs like holiday photos.

Three things I’ll take to the next project:

Load order is part of the design. If a script talks to both Az and Microsoft Graph, decide which connects first and write it down with the reason. It isn’t a style preference; it’s load-bearing.

A test that doesn’t touch the failing path isn’t evidence. Mine proved a method resolved. The failure was in native interop one layer down. When you’re about to declare something fixed, ask what specifically your test exercised, and whether it’s the same thing that was breaking.

Random isn’t always random. In fact, nothing here was random. The assembly conflict appeared only on cold starts, the TAP error only on pre-existing accounts, the subscription error only when a default was set. Every one of them looked flaky until the condition that triggered it was named.

A superhero with arms crossed beside an open vault door, with two identical brass keys hanging in order on a rail on the wall
Nothing was removed and nothing was forced. Only the sequence changed.

The least-privilege build in Part 2 is still running, still as a standard user, and these four fixes are in it. Nothing here argued for giving the script more privilege. It just took longer than expected to make the least-privilege version boring — which is the condition you actually want.

Hit one of these, or a different collision between two Microsoft modules in the same session? Get in touch. I'd like to hear which one found you.