By the end of this you have a single-tenant Graph PowerShell client registered in your own directory, gated so that only an assigned group can obtain tokens through it, consented at the scopes and the audience you chose, validated with a test that proves both the permitted call and the blocked one, and the shared Microsoft client placed behind the same gate with a measured list of who was using it first.
Prerequisites
Before step 1: PowerShell 7 or later with the Microsoft.Graph module installed, which is the supported configuration and needs nothing further, though Windows PowerShell 5.1 works where .NET Framework 4.7.2 or later is present and is not worth defending on a new build. Application Administrator or Cloud Application Administrator to register the application, set its assignment requirement, and grant tenant-wide admin consent for the delegated scopes used here; Privileged Role Administrator is not needed, because that role is required only for Microsoft Graph application permissions and this build grants none. Reports Reader or higher to read the sign-in logs in step 1.
Naming convention
A licence note, because it bites in step 1 rather than at the end: reading sign-in logs through the Graph API requires Microsoft Entra ID P1 or P2, so the verification gate this whole sheet is built on is not licence-free. If you also intend to put an access review over the assignment group, which is recommended and is required for nothing else here, that needs Microsoft Entra ID Governance or Microsoft Entra Suite. A subset of access review capability runs on P2 alone and P1 does not carry it. Nothing in this sheet moves files between machines.
Naming
Use GRAPH-Client-<Function> for the application and GRAPH-Admins-<Function> for the group behind it, where <Function> is the administrative job the client exists to do, not the team that happens to do it today. This sheet builds GRAPH-Client-DirectoryOps and GRAPH-Admins-DirectoryOps. One client per function, not one client per person and not one client for everything: the whole value of owning the object is that its name states what it is for, and a client called GRAPH-Client-General reproduces the problem you are leaving behind.
Step 1: Measure the shared client before you change anything
This is the verification gate in front of everything else. You are about to build a replacement and then gate the incumbent, and the best inventory available of who administers your directory from a prompt is the interactive sign-in history of application 14d82eec-204b-4c2f-b7e8-296a70dab67e. Read it first, and read it knowing it is a floor rather than a census.
Connect-MgGraph -Scopes "AuditLog.Read.All","Application.Read.All","Directory.Read.All"
$sharedAppId = "14d82eec-204b-4c2f-b7e8-296a70dab67e"
$sharedSp = Get-MgServicePrincipal -Filter "appId eq '$sharedAppId'"
$sharedSp | Format-List Id, DisplayName, AppId, AccountEnabled, AppRoleAssignmentRequired
Get-MgAuditLogSignIn -Filter "appId eq '$sharedAppId'" -Top 500 |
Group-Object UserPrincipalName |
Sort-Object Count -Descending |
Select-Object Count, Name
Expected result: a service principal object with an Id value, AppRoleAssignmentRequired reported as False, and a list of user principal names with sign-in counts. Keep that list. It becomes the starting membership of your assignment group and the distribution list for the change notice.
Filter on the application ID and never on the name. The enterprise application can carry one display name in the blade heading and a different one in the Name field on its Properties page, because the local object and the application registration in Microsoft’s tenant were named at different times. A tenant that has been running Graph PowerShell for years will commonly show Microsoft Graph PowerShell where the documentation tells you to look for Microsoft Graph Command Line Tools. Both are the same object and the application ID is the only handle that does not move.
Failure mode to watch for: Get-MgServicePrincipal returning nothing at all. That does not mean the client is unused, it means no one has ever completed an interactive sign-in through it in this tenant and the service principal has not been created yet. Do not proceed to step 8 on that basis. Two further reasons the list understates reality, both of which matter more than they sound: the sign-in logs record sign-ins that are interactive in nature, so a session riding a cached refresh token produces no new event, and the logs retain for a limited window, so a 500-record sample taken over a quiet fortnight is a sample of a quiet fortnight. Widen the query with a date filter across the full retention period, and treat the result as the people you found rather than the people who are there.
Now read the grant that already exists against it, because that string is what you are replacing.
Get-MgOauth2PermissionGrant -Filter "clientId eq '$($sharedSp.Id)'" |
Format-List Id, ConsentType, PrincipalId, ResourceId, Scope
Expected result: one or more grants, typically a single object with ConsentType of AllPrincipals, an empty PrincipalId, and a long space-separated Scope string. Record the scope string verbatim. You will select from it in step 5, and you will need it again if you ever have to reverse step 8 in a hurry.
Step 2: Register the tenant-owned client
In the Microsoft Entra admin center, under App registrations, choose New registration and create the application with the following values.
| Setting | Value | Why |
|---|---|---|
| Name | GRAPH-Client-DirectoryOps | The name is the governance artefact. It appears in the sign-in logs, in consent prompts and in every future access review. |
| Supported account types | Accounts in this organizational directory only | Single tenant. There is no scenario in which your administrative client should be addressable from another directory. |
| Redirect URI platform | Public client/native (mobile & desktop) | The SDK authenticates interactively as a public client. Selecting Web here produces a client that cannot complete the flow and fails with a redirect URI mismatch. |
| Redirect URI value | http://localhost | The loopback redirect the MSAL interactive flow uses when the broker is not in play. |
Assign the application an owner at registration time rather than later. Owners can only be users or service principals, not groups, so name at least two administrative accounts rather than leaving the record at whoever happened to click Register.
Step 3: Add the broker redirect URI, which is derived
Windows Authentication Manager broker sign-in needs a second redirect URI, and that URI contains the application ID Entra generated in step 2. It is a derived value. Do not copy a literal from anywhere, including from this article: read it from your own tenant and let the command build the string.
$app = Get-MgApplication -Filter "displayName eq 'GRAPH-Client-DirectoryOps'"
$app.AppId
$brokerUri = "ms-appx-web://Microsoft.AAD.BrokerPlugin/$($app.AppId)"
$brokerUri
Expected result: a GUID, then a URI of the form ms-appx-web://Microsoft.AAD.BrokerPlugin/ followed by that same GUID. Add both http://localhost and the value of $brokerUri under the Mobile and desktop applications platform on the application’s Authentication page.
Failure mode to watch for: this is the single most common defect in a hand-built Graph client. A broker URI carrying the wrong GUID does not fail at registration and does not fail on every machine. It fails only where the broker is engaged, on Windows, for some users, with an error that names the redirect URI rather than the cause, while Connect-MgGraph keeps working elsewhere through the loopback path. Verify the GUID in the URI matches $app.AppId before you save.
Step 4: Create the group, require assignment, assign it
Order matters. Set the assignment requirement before you write any consent grant, so that there is never a window in which a consented directory write scope sits on a client every account in the tenant can sign into.
In the portal the control is called Assignment required? and it lives on the Properties page of the enterprise application, not on the app registration. Over Graph the same control is the appRoleAssignmentRequired property on the service principal. They are one setting with two names, and the article uses whichever fits the sentence from here. Set it to Yes, then use Users and groups on the same enterprise application to add GRAPH-Admins-DirectoryOps.
Three constraints govern what you can put in that list, and the second one catches people. Group-based assignment requires Microsoft Entra ID P1 or P2, though assigning individual users does not. Nested group membership does not cascade, so only direct members of the group you assign are covered, and assigning a parent group whose admins sit in child groups grants access to nobody. Security groups with dynamic membership are supported.
The same moves without the portal:
$group = New-MgGroup -DisplayName "GRAPH-Admins-DirectoryOps" `
-MailNickname "GRAPH-Admins-DirectoryOps" `
-MailEnabled:$false -SecurityEnabled:$true `
-Description "Principals permitted to obtain Graph tokens through GRAPH-Client-DirectoryOps"
$sp = Get-MgServicePrincipal -Filter "appId eq '$($app.AppId)'"
Update-MgServicePrincipal -ServicePrincipalId $sp.Id -AppRoleAssignmentRequired:$true
New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id -BodyParameter @{
principalId = $group.Id
resourceId = $sp.Id
appRoleId = "00000000-0000-0000-0000-000000000000"
}
Expected result: a group object with an Id, no output from the update, and an app role assignment object naming the group as PrincipalDisplayName. The all-zero appRoleId is the documented default assignment used when an application exposes no app roles of its own, which is the case here.
Failure mode to watch for: Get-MgServicePrincipal returning nothing immediately after registration. Registering an application creates the application object; the service principal in the tenant follows, and in a fresh tenant it can lag by a few seconds. Retry rather than creating a second service principal by hand.
Step 5: Decide the audience, then write the consent grant
There are two shapes of delegated grant and the choice is an authority decision, not a convenience one.
| Setting | Value | Why |
|---|---|---|
ConsentType | AllPrincipals | One grant covering everyone who can sign into the client. On a client with an assignment requirement this is bounded by the group, which is the normal choice for a function the whole group performs. The grant names no person. |
ConsentType | Principal | A grant naming one user in PrincipalId. Use where a scope is needed by one or two people rather than the function, and where you want the grant itself to record who holds it. Costs one object per person and per scope change. |
Scope | Space-separated claim values | One string, not a collection. Writing this property replaces it wholesale, so a careless update silently removes every scope you did not restate. |
Add the permissions to the application first, so the registration declares what it needs, then grant consent. For the group-wide case the supported route is to add the delegated permissions under API permissions on the application and use Grant admin consent for the tenant. For the per-person case, write the grant directly.
$graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
$user = Get-MgUser -UserId "rmarsh@contoso.com"
New-MgOauth2PermissionGrant -BodyParameter @{
clientId = $sp.Id
consentType = "Principal"
principalId = $user.Id
resourceId = $graphSp.Id
scope = "User.ReadWrite.All Group.ReadWrite.All"
}
Expected result: a grant object echoing ConsentType of Principal and the user’s object ID in PrincipalId. Note that clientId and resourceId take service principal object IDs, not application IDs. Passing an appId is the most frequent error here and it returns a resource-not-found response that does not say which of the two identifiers was wrong.
Failure mode to watch for: the grant carries no expiry, and older guidance will tell you otherwise. The beta resource type has startTime and expiryTime properties described as required but ignored, and they are absent from v1.0 entirely. The object you create has clientId, consentType, principalId, resourceId and scope, and nothing else. There is no clock on a delegated permission grant, so the review cadence has to come from you.
Worked example: the joiner-mover-leaver pair
Two engineers run the identity lifecycle scripts. Nobody else in the platform team needs directory write. The service desk needs to read users and reset nothing. Mapping that to exact settings:
| Setting | Value | Why |
|---|---|---|
| Application | GRAPH-Client-DirectoryOps | One client for the identity lifecycle function. The service desk gets its own client later, not a share of this one. |
| Assignment group | GRAPH-Admins-DirectoryOps, membership of two | The assignment requirement means these two are the only accounts that can obtain a token through the client at all, before scopes are considered. |
ConsentType | Principal, twice | Two grants, one per engineer. With a population of two, the per-person record is worth more than the single object, and a leaver is handled by deleting their grant and their group membership rather than by editing a shared string. |
Scope | User.ReadWrite.All Group.ReadWrite.All | The two write scopes the lifecycle scripts actually call. Not Directory.ReadWrite.All, which would also carry application, device and role-adjacent surface neither script touches. |
| Directory role | User Administrator, PIM-eligible | The delegated token is bounded by what the user may do. Making the role eligible rather than permanent means the intersection is empty outside an activated window. |
The last row is the one that does the work. The client controls who can hold the scope; the role controls whether the scope resolves to anything. Both are needed, and an estate that gets the client right and leaves the role permanent has bought itself a cleaner audit trail rather than less standing privilege.
Step 6: Connect through the new client and verify the token
$tenantId = (Get-MgContext).TenantId # read it BEFORE the disconnect
Disconnect-MgGraph
Connect-MgGraph -ClientId $app.AppId -TenantId $tenantId `
-Scopes "User.ReadWrite.All","Group.ReadWrite.All"
Get-MgContext | Format-List ClientId, TenantId, Scopes, AuthType
Expected result: ClientId equal to your application ID and not 14d82eec-204b-4c2f-b7e8-296a70dab67e, and Scopes containing the permissions you granted plus the OpenID Connect scopes. If ClientId still shows the Microsoft value you are looking at a cached context; Disconnect-MgGraph is not optional here and neither is checking the field rather than assuming it.
The ordering in that block is the point. Disconnect-MgGraph ends the session, and once it has run there is no context left for Get-MgContext to read a tenant ID out of. Capture the value first or -TenantId resolves to nothing and the connection falls back to whatever the module decides, which is not what you are trying to prove.
Failure mode to watch for: a sign-in that completes and then reports that the user is not assigned to a role for the application. That is step 4 working. Confirm the account is in GRAPH-Admins-DirectoryOps and that group membership has replicated before assuming the assignment itself is wrong.
Step 7: The end-to-end test, both directions
A test that only proves the permitted call works proves almost nothing. Run the pair.
# Positive: a call the granted scope covers
$g = New-MgGroup -DisplayName "GRAPH-Test-Canary" -MailNickname "GRAPH-Test-Canary" `
-MailEnabled:$false -SecurityEnabled:$true
# Negative: a call no granted scope covers
Get-MgDeviceManagementManagedDevice -Top 1
Remove-MgGroup -GroupId $g.Id
Expected result: the group is created and returns an object, and the device management call fails with an authorization error naming a missing scope rather than returning data. A negative test that returns results means the grant is wider than you intended and you should re-read the Scope string before going further. Delete the canary group in the same session, and note that the client is now visible in the sign-in logs under its own application ID, which is the check that the whole exercise was for.
What breaks, and what does not
Answer this before you touch step 8, because it is the first question anyone will ask and the answer is better than most people expect.
App-only automation is untouched. Certificate credentials, client secret credentials and managed identity all require a -ClientId of your own application, and in those parameter sets it is mandatory rather than optional. There is no path by which a daemon, a runbook or a Function reaches Graph through the shared client. A managed identity authenticates as its own service principal. If your automation has an identity of its own, and it should, nothing in step 8 reaches it.
What breaks is delegated. Administrators at an interactive prompt, device code flow inside scripts, and the ugly one, a scheduled job running under a service account making user-context calls on a cached refresh token. That last pattern is the thing gating the client will find for you, and finding it is a good outcome rather than a cost, because it should have been a certificate-based registration all along.
| Setting | Value | Why |
|---|---|---|
| Principal type on the assignment | User or Group | The list should hold people and the groups holding people. This is an interactive client and the thing being gated is a human sign-in. |
| Principal type on the assignment | ServicePrincipal | Accepted by the API and pointless here, because a service principal does not perform a user sign-in through a public client. Wanting to add one means something downstream is authenticating as a person when it should authenticate as itself. Fix that instead. |
| Membership source | The step 1 sign-in list | Not the org chart and not the admin group you already have. The people using this client are not the people you think, which is the entire reason step 1 exists. |
One distinction to hold on to, because it gets collapsed constantly. Assignment decides whether you can obtain a token through the client. The consent grant decides what scopes that token carries. Your directory roles decide whether those scopes resolve to anything. Turning on Assignment required? changes only the first, and it narrows nobody’s permissions by a single scope.
Step 8: Gate the shared client
This step will break tooling. The verification gate goes immediately before it, not after: reconcile the sign-in population you captured in step 1 against the accounts you have provisioned on owned clients, and confirm the difference is a list you have contacted. Then give yourself a way back. Assign a break-glass group to the shared service principal using the same New-MgServicePrincipalAppRoleAssignment shape as step 4, and confirm you can reach Graph through GRAPH-Client-DirectoryOps. Reversal is a single call, and a single call you cannot authenticate to make is not a reversal.
$sharedSp = Get-MgServicePrincipal -Filter "appId eq '14d82eec-204b-4c2f-b7e8-296a70dab67e'"
if (-not $sharedSp) {
$sharedSp = New-MgServicePrincipal -AppId "14d82eec-204b-4c2f-b7e8-296a70dab67e"
}
# Verification gate: who is still arriving through it?
Get-MgAuditLogSignIn -Filter "appId eq '14d82eec-204b-4c2f-b7e8-296a70dab67e'" -Top 500 |
Group-Object UserPrincipalName | Select-Object Count, Name
# Only then:
Update-MgServicePrincipal -ServicePrincipalId $sharedSp.Id -AppRoleAssignmentRequired:$true
Expected result: no output from the update, and a subsequent Get-MgServicePrincipal reporting AppRoleAssignmentRequired as True. From that point an unassigned member account running a bare Connect-MgGraph receives a sign-in failure stating the user is not assigned to a role for the application, and the Graph PowerShell SDK together with any community module that does not pass its own client ID stops working for that user.
Global Administrators are exempt. Microsoft documents that the assignment requirement does not apply to that role, so this gate reaches routine administration and does not reach the accounts with the most privilege. Treat it as hygiene over day-to-day use rather than as a boundary around your highest reach, and do not present it to a risk committee as the latter.
Reversal is a single call setting the property back to $false, which is why this step is gated rather than forbidden. Deleting the old tenant-wide grant is the irreversible half, and it should follow this by weeks rather than minutes, once the sign-in logs have gone quiet.
| Setting | Value | Why |
|---|---|---|
Assignment required? / appRoleAssignmentRequired | Yes / $true | Gates token issuance to assigned principals. Reversible, and the correct first move. |
Enabled for users to sign-in? / AccountEnabled | No / $false | Disables the service principal entirely. Blunter, blocks every use in the tenant including your own break-glass path, and worth holding in reserve for an incident rather than using as the steady state. |
Delete the AllPrincipals grant | After the logs go quiet | Removes the standing authority. Existing access tokens remain valid until they expire, so this is not an immediate cut-off and should not be scheduled as one. |
Consolidated build
The same build without the portal, for a tenant you are standing up from scratch or for the second and third function clients once the pattern is agreed.
$Function = "DirectoryOps"
$AppName = "GRAPH-Client-$Function"
$GroupName = "GRAPH-Admins-$Function"
$Scopes = "User.ReadWrite.All Group.ReadWrite.All"
Connect-MgGraph -Scopes "Application.ReadWrite.All","Group.ReadWrite.All",
"AppRoleAssignment.ReadWrite.All","DelegatedPermissionGrant.ReadWrite.All"
# 1. Register with the loopback redirect only; the broker URI needs the generated AppId.
$app = New-MgApplication -DisplayName $AppName -SignInAudience "AzureADMyOrg" `
-PublicClient @{ RedirectUris = @("http://localhost") } `
-IsFallbackPublicClient:$true
# 2. Add the derived broker redirect URI, read from the object we just created.
Update-MgApplication -ApplicationId $app.Id -PublicClient @{
RedirectUris = @("http://localhost", "ms-appx-web://Microsoft.AAD.BrokerPlugin/$($app.AppId)")
}
# 3. Service principal, group, assignment requirement, assignment.
$sp = New-MgServicePrincipal -AppId $app.AppId
$group = New-MgGroup -DisplayName $GroupName -MailNickname $GroupName `
-MailEnabled:$false -SecurityEnabled:$true
Update-MgServicePrincipal -ServicePrincipalId $sp.Id -AppRoleAssignmentRequired:$true
New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id -BodyParameter @{
principalId = $group.Id; resourceId = $sp.Id
appRoleId = "00000000-0000-0000-0000-000000000000"
}
# 4. Grant, tenant-wide but bounded by the assignment requirement.
$graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
New-MgOauth2PermissionGrant -BodyParameter @{
clientId = $sp.Id; consentType = "AllPrincipals"; resourceId = $graphSp.Id
scope = $Scopes
}
# 5. Prove it. Capture the tenant ID before the disconnect destroys the context.
$TenantId = (Get-MgContext).TenantId
Disconnect-MgGraph
Connect-MgGraph -ClientId $app.AppId -TenantId $TenantId -Scopes $Scopes.Split(' ')
(Get-MgContext).ClientId
(Get-MgContext).Scopes
Expected result from the last two lines: your application ID, and a scope collection containing User.ReadWrite.All and Group.ReadWrite.All. Run the step 7 pair against it before you consider the client built. Every literal in this block except the two Microsoft application IDs is either a variable or read back from an object the script created, which is deliberate: the application ID is generated and nothing here should be retyped from a page.
Ongoing review
Put an access review over GRAPH-Admins-DirectoryOps on the same cadence as your privileged role reviews, and read the grant itself on the same schedule with the step 1 query pointed at your own client. The two questions are different: the group answers who can obtain a token, the grant answers what that token carries. An estate that reviews the first and never re-reads the second ends up back where it started, with a scope string nobody chose in one sitting.
The next client is the same build with a different $Function. Resist the pull to widen this one.
Doctrine: The Graph Client Nobody Owns




