Detecting shadow accounts in Entra ID with PowerShell

Share this post:

Reviewing a tenant for an access governance project, I noticed something odd. A senior consultant had three MFA phone numbers registered in Entra ID. Two of them belonged to a guest account from a project two years earlier, created during onboarding and never cleaned up. The account everyone actually used for daily work had none of those numbers.

That second account is what I call a shadow account. A second (or third, or fourth) directory object representing the same physical person, holding identifiers, group memberships, app assignments, and authentication methods that nobody is actively governing. They are common, and they are quietly expensive.

Why shadow accounts cost you

The most visible cost is the one I just described. Run an access review without finding the shadow accounts first, and you review the wrong objects. The reviewer approves access on the account they recognize, while the dormant one keeps everything attached to it.

There are quieter costs too:

  • A proxyAddress on a forgotten account blocks you from assigning the same SMTP address to a new user months later, with no obvious explanation in the error message.
  • A user resets MFA on what they think is their account. The phishing-resistant authenticator they just registered actually went on the dormant object. Sign-in still works some of the time, so they never notice the drift.
  • An attacker who compromises a dormant guest account inherits whatever group memberships the account still has. Nobody is monitoring sign-ins on that object, because nobody remembers it exists.

These are not theoretical. I have found at least one of these patterns in every tenant I have reviewed in the last year.

What counts as a shadow account

The definition I use is simple. Two user objects in the same tenant share at least one identifier that should be unique to a person:

  • An email address (UPN, mail, otherMails, proxyAddresses)
  • A phone number (mobilePhone, businessPhones)
  • A federated identity (the identities array, emailAddress or phoneNumber sign-in)
  • An MFA or SSPR contact method (registered email or phone)

One overlap is a candidate. Two overlaps is almost certainly the same person.

What the script collects

For every user in the tenant, it pulls:

  • Core directory attributes (UPN, mail, otherMails, proxyAddresses, mobilePhone, businessPhones, userType, createdDateTime)
  • The identities array (B2B and federated identifiers)
  • Authentication methods registered for MFA and SSPR (email and phone)

Each identifier is normalized before comparison. Email addresses go to lower case. Phone numbers go to E.164 format, so a number stored as +47 99 99 99 99 collides correctly with 0047 99999999. Without that step, the dataset is too noisy to act on.

Three CSVs you actually use

The script writes up to four files. Two are the working set.

ShadowCandidates_<timestamp>.csv
Pairs of users that share an identifier, with reason (EmailCollision or PhoneCollision), the overlapping value, optional name similarity (Jaro-Winkler), and a 0 to 100 risk score. Sort descending by RiskScore and the top of the list is your homework.

Duplicates_<timestamp>.csv
Identifiers used by more than one user, with the count. Useful when you want to see whether a phone number is registered to five accounts. It happens, often a shared service identity that someone reused.

Two more are produced on demand:

  • UserInventory_<timestamp>.csv with -ExportInventory: every user with all collected identifiers, concatenated. Good for offline analysis in Excel or Power BI.
  • Matches_<timestamp>.csv: results of -Phone-PhonePartial, or -Email lookups, when you are hunting for a specific number or address.

Typical usage

Three patterns cover the common questions:

# Full tenant scan, all artifacts
./Find-EntraShadowAccounts.ps1 -AllUsers -ExportInventory

# Pivot around one or more seed users
./Find-EntraShadowAccounts.ps1 -UserQuery 'anna.olsen@contoso.com' -SelectWithGrid

# Fast path: hunt for a specific phone number across the tenant
./Find-EntraShadowAccounts.ps1 -PhonePartial '99 99' -MatchesOnlyPhones

On a 500 user tenant the full scan finishes in under a minute. On a 5000 user tenant it takes about four minutes, because the Graph endpoint for authentication methods cannot be batched and has to be called once per user.

Reading the output

ShadowCandidates rows with a high risk score are the ones to look at first. Typical patterns:

  • Same display name, shared email address, one Member and one Guest: a guest invitation that lingered after the person was hired internally.
  • Same phone number on three accounts: shared service identity, or SSPR registration that drifted across role changes.
  • Shared proxyAddress between two users: split-brain after a tenant migration that was never fully completed.

Before any access review, this list goes through governance: keep, merge, or delete. Doing this once a quarter means the access reviewers stop approving access on objects that should not exist in the first place.

Where to get it

The script lives on GitHub as part of my EntraGate work.

Source and latest version: maskovli/entragate, Find-EntraShadowAccounts.ps1

Pull requests welcome. If you find a tenant shape that breaks the heuristics (mass-imported guest accounts with no normalized phone, for example), open an issue.

Subscribe to our newsletter

Get the inside scoop! Sign up for our newsletter to stay in the know with all the latest news and updates.

Don’t forget to share this post!

Leave a Comment

Scroll to Top
Troll

Contact us

Troll