Do You Really Need a Custom Entra App Just to Run Graph PowerShell?

Every Graph PowerShell session in your tenant shares the same Microsoft app registration—and every consented permission that goes with it. Custom Entra apps let you carve separate boundaries, but the operational cost is real.

Run Connect-MgGraph right now and check what app your session is riding on. It’s 14d82eec-204b-4c2f-b7e8-296a70dab67e — “Microsoft Graph Command Line Tools.” A first-party, multi-tenant app that Microsoft owns and every admin, help desk tech, and curious intern in your tenant shares. When your security lead consents to AuditLog.Read.All through that app, they’re widening the scope surface of the same registration the help desk uses to look up phone numbers.

That’s the itch Office365ITPros scratched yesterday: replace the shared app with purpose-built Entra registrations so each team gets its own delegated permission boundary. Sound like least privilege done right? Mostly. But I’ve watched this pattern turn into a graveyard of half-forgotten app registrations, so let me argue with myself about it before you commit.

What the shared app actually grants

Here’s the nuance people skip. Delegated permissions don’t hand a user powers they don’t already have. The effective permission is the intersection of the granted scope and the user’s directory role. A help desk tech with User.ReadWrite.All consented on the shared app still can’t reset a Global Admin’s password if their role doesn’t allow it.

So why care? Two reasons. First, the shared app accumulates consented scopes over time — every admin who clicks through a consent prompt adds to the pile, and now a stolen or phished token can request a much wider set of scopes than that user needs. Second, the audit story is muddy. Every session looks identical in the logs because it’s the same AppId. You can tell who signed in, but the app tells you nothing about why.

My skeptical colleague, who has done this before, pushes back

“Hang on. You just said delegated perms are gated by the user’s role. So the blast radius is already bounded. What does a custom app buy me that PIM and Conditional Access don’t already cover?”

Fair hit, and it’s the one that actually decides this. CA and PIM gate who connects and when — device compliance, MFA, a four-hour eligible window. Neither of them scopes what the token can touch inside Graph. A conditionally-approved, PIM-elevated session on the shared app can still request every scope that app has ever been consented to. A custom app pins the ceiling: the help desk app only ever holds User.Read.All, so a token minted from it literally cannot ask for more. That’s the layer CA and PIM don’t provide.

“Okay, but now I’ve got a Helpdesk app, a Security app, an Exchange-ops app, each with its own consent grant, its own owner, its own review cadence. You’ve traded one shared thing for five bespoke things to forget about.”

Yes. That’s the real cost, and the source is quiet about it. Every app you create is another line in your access reviews and another orphan waiting to happen when the owner leaves. If you’re a 200-seat tenant with three admins, this is overkill — tighten the shared app’s consented scopes and move on. If you’re a regulated shop where “the help desk can read audit logs” is a finding, the boundary is worth the bookkeeping. Splitting the difference badly is the failure mode: don’t create apps you won’t govern.

Building one, without leaving debris

You need Application Administrator or Cloud Application Administrator to create the registration; granting admin consent for the API permissions needs Privileged Role Administrator or Global Admin. No premium licence is required to register apps — Entra ID Free covers it. The premium tier earns its keep later, on the logs: longer sign-in and audit retention with P1.

Define the app in a hashtable first so you can read it before anything is created. It’s a public client — no secret, no certificate — with the loopback redirect the SDK expects:

audit.ps1PowerShell
Connect-MgGraph -Scopes "Application.ReadWrite.All","DelegatedPermissionGrant.ReadWrite.All"
​
$appParams = @{
    DisplayName            = "Graph PS - Helpdesk (Delegated)"
    SignInAudience         = "AzureADMyOrg"          # single tenant
    IsFallbackPublicClient = $true                    # public client, no secret
    PublicClient           = @{ RedirectUris = @("http://localhost") }
}
​
# Dry run: eyeball the request body before you send it
$appParams | ConvertTo-Json -Depth 5
​
try {
    $app = New-MgApplication @appParams -ErrorAction Stop -Confirm
    Write-Host "Created '$($app.DisplayName)' - client ID $($app.AppId)"
}
catch {
    Write-Error "App registration failed: $($_.Exception.Message)"
    return
}

Now attach the one delegated scope this app is allowed to hold. Resolve the permission ID from the Graph service principal rather than pasting a GUID you can’t verify:

audit.ps1PowerShell
$graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
$scope   = $graphSp.Oauth2PermissionScopes | Where-Object Value -eq 'User.Read.All'
​
if (-not $scope) { Write-Error "Scope not found on Graph SP"; return }
​
$rra = @{
    ResourceAppId  = $graphSp.AppId
    ResourceAccess = @(@{ Id = $scope.Id; Type = "Scope" })   # Scope = delegated
}
​
Update-MgApplication -ApplicationId $app.Id -RequiredResourceAccess @($rra) -Confirm

Adding the permission doesn’t grant it — the user still hits a consent prompt on first connect, or you pre-consent with New-MgOauth2PermissionGrant. Then point the SDK at your app instead of the Microsoft default:

audit.ps1PowerShell
Connect-MgGraph -ClientId $app.AppId -TenantId "<your-tenant-id>" -Scopes "User.Read.All"

Ship that -ClientId to the help desk in a signed profile function so nobody’s guessing GUIDs. That single flag is the entire behavioural change on the operator’s side.

Proving it in the logs

This is where the split pays off. Sign-ins now carry your app ID, so you can slice activity per team instead of untangling one blob. Page through with -All — sign-in logs are long:

audit.ps1PowerShell
try {
    Get-MgAuditLogSignIn -Filter "appId eq '$($app.AppId)'" -All -ErrorAction Stop |
        Select-Object CreatedDateTime, UserPrincipalName, AppDisplayName, IpAddress |
        Sort-Object CreatedDateTime -Descending |
        Format-Table -AutoSize
}
catch {
    Write-Error "Sign-in log query failed: $($_.Exception.Message)"
}

Consent and permission changes land in the directory audit logs as “Add delegated permission grant” and “Consent to application” events — filter those to catch scope creep on the app you just built. In the portal: Entra admin center → Identity → Monitoring & health → Sign-in logs (filter by application), and Audit logs for the consent trail. The registration itself lives under Identity → Applications → App registrations.

The honest verdict: this isn’t a security control on its own, and anyone selling it as one is overreaching. It’s a scoping and auditing boundary that only earns its keep when you actually run the access reviews it creates. Build the app you’ll govern. Skip the four you won’t.