Delegated or application permissions
Microsoft Graph has two kinds of permissions. Delegated permissions act on behalf of a signed-in user and can never do more than that user can. Application permissions act as the application itself, with no user, across the whole tenant unless something else limits them. Scripts, background services and integrations almost always use application permissions. That is why they deserve the most attention.
A few application permissions are effectively keys to the tenant: Mail.ReadWrite and Mail.Send for every mailbox, Files.ReadWrite.All and Sites.FullControl.All for every document, Application.ReadWrite.All, AppRoleAssignment.ReadWrite.All and RoleManagement.ReadWrite.Directory, which let an app grant itself or others more rights.
Rule 1: one app per purpose, the narrowest permission that works
The Graph reference documentation lists, for every API call, the least privileged permission that works. Start there, not with the permission that made the error go away. And do not collect permissions in one "integration" app: a mail relay, a reporting job and a provisioning script each get their own registration, so each can be scoped, rotated and removed on its own.
The same applies to tools that manage tenants. In the M365 Control Plane I built, every customer tenant gets its own single-tenant app registration, and tenants that are only audited get a separate app with read permissions and the Global Reader role. A write-capable app for audit work would be a risk nobody needs.
Rule 2: scope what Graph cannot scope
Mailboxes: RBAC for Applications
An application permission like Mail.Send granted in Entra ID applies to every mailbox. Exchange Online can limit it. The older mechanism is the application access policy, which restricts an app to the members of a mail-enabled security group:
Connect-ExchangeOnline
New-ApplicationAccessPolicy -AppId "<application id>" -PolicyScopeGroupId "app-mail-relay@example.com" `
-AccessRight RestrictAccess -Description "Mail relay app: only the relay mailboxes"
Test-ApplicationAccessPolicy -Identity "someone.else@example.com" -AppId "<application id>"
Microsoft now recommends RBAC for Applications instead: the permission is granted inside Exchange Online and limited to a management scope.
New-ServicePrincipal -AppId "<application id>" -ObjectId "<enterprise app object id>" -DisplayName "Mail relay"
New-ManagementScope -Name "Scope-MailRelay" -RecipientRestrictionFilter "CustomAttribute10 -eq 'GraphMailSend'"
New-ManagementRoleAssignment -App "<enterprise app object id>" -Role "Application Mail.Send" -CustomResourceScope "Scope-MailRelay"
Test-ServicePrincipalAuthorization -Identity "<enterprise app object id>" -Resource "noreply@example.com"
The classic mistake: granting Mail.Send with admin consent in Entra ID and creating the scoped role assignment. Permissions from both sources add up, so the tenant-wide grant wins. With RBAC for Applications, the Entra ID grant must not exist.
SharePoint: Sites.Selected
An app that needs one site does not need Sites.ReadWrite.All. Grant it Sites.Selected, which on its own gives access to nothing, and then grant access per site:
Connect-MgGraph -Scopes "Sites.FullControl.All"
$site = Get-MgSite -SiteId "example.sharepoint.com:/sites/finance-reports"
New-MgSitePermission -SiteId $site.Id -BodyParameter @{
roles = @("read")
grantedToIdentities = @(@{ application = @{ id = "<application id>"; displayName = "Finance report export" } })
}
The administrator who runs this needs high rights once. The app itself only ever sees that one site.
Rule 3: certificates, or no credential at all
A client secret is a password. It ends up in configuration files, scripts and tickets. A certificate whose private key cannot be exported stays on the machine that uses it.
$cert = New-SelfSignedCertificate -Subject "CN=mail-relay" -CertStoreLocation "Cert:\CurrentUser\My" `
-KeyExportPolicy NonExportable -KeySpec Signature -KeyLength 2048 -KeyAlgorithm RSA `
-HashAlgorithm SHA256 -NotAfter (Get-Date).AddMonths(12)
Export-Certificate -Cert $cert -FilePath ".\mail-relay.cer" # public key only: upload this to the app registration
# Sign in with it
Connect-MgGraph -ClientId "<application id>" -TenantId "<tenant id>" -CertificateThumbprint $cert.Thumbprint
Connect-ExchangeOnline -AppId "<application id>" -CertificateThumbprint $cert.Thumbprint -Organization "example.onmicrosoft.com"
For a service account that runs as a Windows service, create the certificate in that account's store or in the machine store with access limited to that account. For automation that runs in Azure, a managed identity is better still: there is no credential to store, rotate or leak.
Rule 4: review what is already there
Most tenants have more app registrations than anyone remembers. Three checks find most of the problems.
Which apps hold high-risk Graph application permissions
Connect-MgGraph -Scopes "Application.Read.All"
$graph = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
$names = @{}; $graph.AppRoles | ForEach-Object { $names[$_.Id] = $_.Value }
$risky = "Mail.ReadWrite","Mail.Send","Files.ReadWrite.All","Sites.FullControl.All","Sites.ReadWrite.All",
"Directory.ReadWrite.All","User.ReadWrite.All","Application.ReadWrite.All",
"AppRoleAssignment.ReadWrite.All","RoleManagement.ReadWrite.Directory"
Get-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $graph.Id -All |
Where-Object { $risky -contains $names[$_.AppRoleId] } |
Select-Object PrincipalDisplayName, @{ n = "Permission"; e = { $names[$_.AppRoleId] } }, CreatedDateTime |
Sort-Object PrincipalDisplayName
Which credentials expire soon, and which apps still use secrets
$limit = (Get-Date).AddDays(30)
Get-MgApplication -All -Property DisplayName, AppId, PasswordCredentials, KeyCredentials | ForEach-Object {
$app = $_
@($app.PasswordCredentials | ForEach-Object { [pscustomobject]@{ App = $app.DisplayName; Type = "Secret"; Expires = $_.EndDateTime } }) +
@($app.KeyCredentials | ForEach-Object { [pscustomobject]@{ App = $app.DisplayName; Type = "Certificate"; Expires = $_.EndDateTime } })
} | Where-Object { $_.Type -eq "Secret" -or $_.Expires -lt $limit } | Sort-Object Expires
Who can consent
If every user may consent to any app, attackers only need a convincing consent prompt. Restrict user consent to verified publishers and low-impact permissions, or switch it off and enable the admin consent workflow so requests reach a person. Review existing delegated grants with Get-MgOauth2PermissionGrant -All.
Finally, check ownership. Every app registration needs an owner who can say what it is for. An app nobody can explain is a candidate for removal: disable the service principal first, wait, then delete.
Checklist
- One app registration per purpose, single-tenant unless there is a reason
- Least privileged permission per API call, taken from the Graph reference
- Mailbox access scoped with RBAC for Applications, no parallel grant in Entra ID
- SharePoint access through
Sites.Selectedand per-site grants - Certificates with non-exportable keys, or managed identities, instead of secrets
- Expiry monitored, renewal documented
- High-risk application permissions reviewed quarterly
- User consent restricted, admin consent workflow enabled
- Every app has an owner and a purpose; unexplained apps disabled, then removed