The Back Story
If you’ve spent any time wiring up Microsoft Entra ID resources with the Microsoft Graph Bicep extension, you’ll have run into Microsoft.Graph/users. It’s documented here:
Microsoft.Graph users reference (graph-bicep-1.0)
Microsoft.Graph/users is a read-only resource type - x-bicep-resource-writable-scopes: None. It can only ever be declared with the existing keyword; Bicep will never create or modify a user through this resource type.
That read-only nature makes it perfect for exactly one job: resolving an existing user’s object ID (and a handful of profile properties) by User Principal Name, so you can feed that ID into something else - most commonly the ownerIds/memberIds parameters of a Microsoft.Graph/groups module, without ever hardcoding a GUID in your Bicep files.
It works brilliantly… right up until one of those users is a B2B guest instead of a native member. That’s where I hit a wall, and where this post picks up.
The Problem
Working for a managed service provider (MSP) sometimes means most of your Bicep deployments run against customer tenants rather than your own - typically authenticated as an admin account that’s been invited into each customer tenant as a Microsoft Entra B2B guest. When I first wired up a simple user-lookup module for exactly this scenario, I passed in the account’s normal email address as the userPrincipalName - and the lookup failed every time.
The reason is how Entra ID handles B2B guest sign-in names. When you invite jane.doe@contoso.com into fabrikam.onmicrosoft.com as a guest, Entra ID doesn’t store the UPN as-is. It rewrites it into a mangled form:
| |
The pattern is:
- Take the invited email address.
- Replace the
@with_. - Append
#EXT#@<resource tenant's initial/verified domain>.
It’s a completely deterministic string transform - no directory lookup is required to compute it - but Bicep has no way of knowing whether a given address belongs to a guest or a member in the target tenant. That’s a fact about the tenant’s directory, not something you can derive from the string itself. So the caller has to tell it.
Bicep Config Configuration
Before any of the module code below will resolve, Bicep needs to know what microsoftGraphv1 actually refers to. That mapping doesn’t live in the module itself - it’s declared once in a bicepconfig.json file sitting alongside your Bicep files (or in a parent folder).
Per the official quickstart, add an extensions block that pins the alias to the Microsoft Graph extension package published to the Microsoft Artifact Registry:
| |
The key on the left (microsoftGraphv1 here) is just a friendly alias - it has to match, character-for-character, whatever name you use in the extension microsoftGraphv1 statement at the top of your Bicep file. Get the casing out of sync between bicepconfig.json and your .bicep file and Bicep will fail to resolve the extension.
A couple of other things worth checking before you start:
Bicep CLI version
You’ll need at leastv0.36.1for Microsoft Graph extension support.
Runaz bicep version(orbicep --versionif you’re using the standalone CLI) and upgrade withaz bicep upgradeif needed.VS Code Bicep extension
Install/update the Bicep extension so IntelliSense picks up the Microsoft Graph types oncebicepconfig.jsonis in place.Permissions
Per theMicrosoft.Graph/usersreference, reading anexistinguser needs at leastUser.Readfor a signed-in (delegated) identity, orUser.Read.Allfor an application/service principal identity. Higher-privileged scopes likeUser.ReadWrite.AllorDirectory.Read.Allalso work, but aren’t required just for lookups.
With bicepconfig.json in place, extension microsoftGraphv1 at the top of the module resolves correctly and the Microsoft.Graph/users@v1.0 resource type becomes available.
The Solution
I extended the plain UPN-lookup pattern into a small reusable module that accepts an isGuest flag per user, and reconstructs the mangled UPN automatically when needed.
| |
Walking Through It
A few things worth calling out for anyone adapting this:
userLookuptype - each entry pairs the invited UPN with anisGuestflag. This keeps the caller-facing interface identical whether you’re resolving a native member or a guest; the mangling is entirely internal to the module.resolvedUserPrincipalNames- a singleforexpression does the mangling up front, using the null-coalescinguser.?isGuest ?? falsepattern soisGuestcan be omitted entirely for native members.userResourcesexisting loop - this is still the same one-liner pattern as the official docs, just fed the resolved (mangled where needed) UPNs instead of the raw ones.- Outputs are index-aligned -
userIds,displayNamesandmailsall come back in the same order as theusersarray you passed in, so you can safely zip them back together in the calling module.
guestTenantDomain only needs to be supplied once per module call, even if you’re resolving a mix of guest and member accounts in the same array - it’s applied only to entries where isGuest is true. Get the value wrong (e.g. use the home tenant domain instead of the resource tenant’s initial domain) and the lookup will silently fail to match, since Graph will simply not find a user with that mangled UPN.
Using It: Resolving Our Deployment Identity
In an MSP-style deployment model, the automation account authenticates as an admin identity that is always a B2B guest in whichever customer tenant it’s currently deploying into. Resolving its object ID (for example, to seed as the owner of a newly created Entra ID group) now looks like this:
| |
From here, getEntraIdDeploymentUser.outputs.userIds[0] drops straight into an ownerIds/memberIds array on a downstream groups module - no manual object ID lookups, no copy-pasting GUIDs between the Entra admin center and a parameters file, and no drift when the deployment is re-run against a different customer tenant.
Why This Matters
The official Microsoft.Graph/users pattern works great in a single-tenant world where everyone deploying is a native member. The moment you’re working as a managed service provider - where the identity running the deployment is a guest in every customer tenant you touch - the plain UPN lookup breaks silently, and it’s not obvious why unless you already know about Entra’s UPN mangling behaviour.
Wrapping that mangling logic into the lookup module itself means:
- Callers keep passing in the UPN they actually know (the invited email address), not some Graph-internal representation of it.
- The
isGuest/guestTenantDomaindistinction is explicit and self-documenting at the call site. - The module composes cleanly with the rest of a Microsoft Graph Bicep module set - resolved user IDs feed straight into groups, app role assignments, or anything else that needs a
principalId.
If your organisation only ever deploys as native members, you can drop isGuest/guestTenantDomain entirely and this collapses back to the plain official pattern - the userLookup type defaults isGuest to false, so existing callers don’t need to change.
Conclusion
The official Microsoft.Graph/users lookup pattern is simple and works well - as long as every account you’re resolving is a native member of the tenant you’re deploying into. That assumption breaks down the moment you’re working cross-tenant with guest accounts, which is the everyday reality for anyone deploying Bicep on behalf of a customer or partners rather than into their own home tenant.
Wrapping the B2B UPN-mangling logic into the module itself means that reality doesn’t leak into every deployment that needs it:
You keep referencing people by the UPN you actually know - the address they were invited with - instead of hand-crafting the
local-part_domain#EXT#@resourceTenantDomainform yourself, or worse, hardcoding a resolved object ID that will break the moment the deployment targets a different tenant.The
isGuest/guestTenantDomainparameters make the guest-vs-member distinction an explicit, self-documenting part of the module’s interface, rather than a tribal-knowledge gotcha that only bites you at deployment time.It composes cleanly with everything else in your Microsoft Graph Bicep estate - resolved object IDs plug straight into
ownerIds/memberIdson a groups module,principalIdon a role assignment, or anywhere else a directory object reference is needed.
If you only ever deploy as a native member, none of this changes anything for you - isGuest defaults to false and the module behaves exactly like the plain official pattern. But if guest identities are part of your day-to-day deployment story, this small extension turns a silent, confusing lookup failure into something that just works.