---
title: "Stop Service Principal Sprawl in Entra ID with PowerShell"
description: "Inventory every app registration and service principal, then automate ownership, rotation, and safe disablement with PowerShell and Microsoft Graph."
canonical: "https://adamtheautomator.com/stop-service-principal-sprawl-entra-id-powershell/"
---

# Stop Service Principal Sprawl in Entra ID with PowerShell

> Inventory every app registration and service principal, then automate ownership, rotation, and safe disablement with PowerShell and Microsoft Graph.

Source: https://adamtheautomator.com/stop-service-principal-sprawl-entra-id-powershell/

---

ATA Learning

Tap to hide

[

ATA Learning

](/)

*   [Home](/)
*   [Tutorials](/tutorials/)
*   [Instructors](/author/)
*   [Advertising](/advertising/)
*   [Recommended Resources](/resources/)
*   [About Adam](/about-adam/)

Search for:  

*   [](https://twitter.com/adbertram)
*   [](https://github.com/Adam-the-Automator)
*   [](https://www.linkedin.com/company/adam-the-automator-llc)
*   [](/feed/)

![Stop Service Principal Sprawl in Entra ID with PowerShell](https://adamtheautomator.com/wp-content/uploads/publisher/2e05d9c85b2b81c080b8d45ec1cdfbb3/71dd588b2369d52e1695c95c1493383c6c0ed2adb4aac7eba04c1ab324f3a6a6.webp)

# Stop Service Principal Sprawl in Entra ID with PowerShell

[![](https://secure.gravatar.com/avatar/d0b9d42e21e5622713f8b693aa5c0f9244d5f7dd200ed29b8398f52dee5de337?s=192&d=mm&r=g)Adam Bertram](https://adamtheautomator.com/author/adam-bertram/)2 October 202616 min. read

Categories: [Information Security](/category/infosec/)

Tags:[Microsoft Entra](/tag/microsoft-entra/)[Microsoft Graph](/tag/microsoft-graph/)[PowerShell](/tag/powershell/)[Security](/tag/security/)

Table of Contents

*   [Prerequisites and Least-Privilege Graph Scopes](#prerequisites-and-least-privilege-graph-scopes)
*   [The Lifecycle Pipeline You Will Build](#the-lifecycle-pipeline-you-will-build)
*   [Why Service Principal Sprawl Is a Governance Problem](#why-service-principal-sprawl-is-a-governance-problem)
*   [The Object Split That Causes Most Cleanup Mistakes](#the-object-split-that-causes-most-cleanup-mistakes)
*   [What the Vendor Telemetry Actually Says](#what-the-vendor-telemetry-actually-says)
*   [Four Failure Classes, Four Countermeasures](#four-failure-classes-four-countermeasures)
*   [Step 1: Build the Service Principal Management Inventory](#step-1-build-the-service-principal-management-inventory)
*   [Connect Using the Smallest Scope Set](#connect-using-the-smallest-scope-set)
*   [Collect Both Object Collections](#collect-both-object-collections)
*   [Step 2: Flag Orphaned, Over-Privileged, and Dormant Principals](#step-2-flag-orphaned-over-privileged-and-dormant-principals)
*   [The Ownership Query Costs You Speed](#the-ownership-query-costs-you-speed)
*   [Rank the Privilege Each Principal Holds](#rank-the-privilege-each-principal-holds)
*   [Detect Dormancy From Sign-In Data](#detect-dormancy-from-sign-in-data)
*   [Step 3: Enforce Ownership and Accountability](#step-3-enforce-ownership-and-accountability)
*   [Why One Owner Is Not an Owner Policy](#why-one-owner-is-not-an-owner-policy)
*   [Step 4: Separate Expiring Credentials from Unused Credentials](#step-4-separate-expiring-credentials-from-unused-credentials)
*   [Scan Expiry From the Object](#scan-expiry-from-the-object)
*   [Ask the Recommendations Engine About Use](#ask-the-recommendations-engine-about-use)
*   [Step 5: Rotate Secrets and Certificates Without Downtime](#step-5-rotate-secrets-and-certificates-without-downtime)
*   [Overlap, Then Retire](#overlap-then-retire)
*   [Step 6: Remediate Safely by Disabling Before Deleting](#step-6-remediate-safely-by-disabling-before-deleting)
*   [Verify Before You Delete](#verify-before-you-delete)
*   [Step 7: Schedule the Sweep and Alert on Credential Changes](#step-7-schedule-the-sweep-and-alert-on-credential-changes)
*   [Wire the Identity Into a Runbook](#wire-the-identity-into-a-runbook)
*   [From Report to Alert](#from-report-to-alert)
*   [Frequently Asked Questions](#frequently-asked-questions)
*   [Do I Need a Paid License for Any of This?](#do-i-need-a-paid-license-for-any-of-this)
*   [What Is the Difference Between an Expiring and an Unused Credential?](#what-is-the-difference-between-an-expiring-and-an-unused-credential)
*   [Can I Just Delete Every Service Principal With No Owner?](#can-i-just-delete-every-service-principal-with-no-owner)
*   [Should I Use a Certificate Instead of a Secret?](#should-i-use-a-certificate-instead-of-a-secret)
*   [How Do I Stop This From Coming Back?](#how-do-i-stop-this-from-coming-back)
*   [Wrapping Up](#wrapping-up)

Don’t let the service principal you create this quarter, the local identity your tenant issues so an application can be authorized here, become somebody else’s audit finding next year.

[An app registration receives a client secret](https://adamtheautomator.com/azure-service-principal/), a project ships, and the credential stays in your directory with permission to read every mailbox and write to every site collection you own. No owner answers for it, and nothing warns you when it turns up in somebody else’s breach disclosure. Most administrators learn the real size of that exposure in an incident review, enumerating every identity nobody has touched in two years. Automation you can run on a schedule closes that gap. Service principal management starts with an inventory: every app registration and service principal in the tenant, classified by ownership, privilege, and credential state. Then you build a lifecycle sweep on top of it: it warns owners before a secret expires, rotates credentials through an overlap window, and disables or deletes the identities nobody is willing to defend. Because the sweep runs under a managed identity, the governance tooling never becomes another orphaned credential.

## Prerequisites and Least-Privilege Graph Scopes

The sweep needs a narrow permission set, and every entry can live on a dedicated automation identity rather than your administrator account.

| Job the sweep performs | Least-privilege Graph permission | What the permission unlocks |
| --- | --- | --- |
| Inventory app registrations and service principals | `Application.Read.All` (`Directory.Read.All` also covers this, with more privilege) | [`Get-MgApplication`](https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.applications/get-mgapplication) and [`Get-MgServicePrincipal`](https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.applications/get-mgserviceprincipal) |
| Read ownership relationships | `Application.Read.All` | [`Get-MgServicePrincipalOwner`](https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.applications/get-mgserviceprincipalowner) and the application owners endpoint |
| Read app-only permission grants | `Application.Read.All` | [`appRoleAssignment`](https://learn.microsoft.com/en-us/graph/api/resources/approleassignment) records |
| Add or remove credentials | `Application.ReadWrite.All`, or `Application.ReadWrite.OwnedBy` for app-only calls on owned objects | `addPassword`, `removePassword`, `addKey`, `removeKey` |
| Disable or delete an identity | `Application.ReadWrite.All` | `accountEnabled` and service principal deletion |
| Read unused-credential findings | `DirectoryRecommendations.Read.All` | The Entra recommendations engine |
| Read activity history | `AuditLog.Read.All` | Service principal sign-ins and directory audit events |

A read-only audit needs rows one through three plus `AuditLog.Read.All` for the sign-in data in step two, so request those four first. Add the write scopes when you are ready to rotate. Check the [Microsoft Graph permissions reference](https://learn.microsoft.com/en-us/graph/permissions-reference) when a call returns a 403; it carries the scope for every job in the table.

To follow along, you’ll also need:

*   A Microsoft Entra tenant where your account can write credentials through a directory role, or owns the application itself. Which role you need depends on the operation and the object type; Global Reader covers the inventory alone.
    
*   PowerShell 7.4 or later with the `Microsoft.Graph` module, tested here against `Microsoft.Graph` 2.19.0, plus the `Az.KeyVault` module for the rotation in step five. Skip the retired `AzureAD` and `MSOnline` modules; Microsoft publishes [migration paths and cmdlet mappings](https://learn.microsoft.com/en-us/powershell/entra-powershell/entra-powershell-faqs?view=entra-powershell) to the newer modules.
    
*   A machine identity for the scheduled sweep: a [system-assigned managed identity](https://adamtheautomator.com/secure-azure-managed-identities/) on an [Azure Automation account](https://learn.microsoft.com/en-us/azure/automation/enable-managed-identity-for-automation), or a service principal authenticated with a certificate. The choice changes how step seven connects.
    
*   Optional, and licensed: [downloading sign-in logs through the Graph API](https://learn.microsoft.com/en-us/graph/api/resources/signin) needs an Entra ID P1 or P2 license; unused-credential detection needs a license that surfaces the [Entra ID recommendations](https://learn.microsoft.com/en-us/entra/identity/monitoring-health/overview-recommendations) engine. Without either, record the capability as unavailable.
    

### The Lifecycle Pipeline You Will Build

Each stage writes an artifact before the next starts, so a late failure never discards earlier evidence.

1.  Inventory every app registration and service principal.
    
2.  Flag orphaned, over-privileged, and dormant principals.
    
3.  Enforce ownership so a named human answers for each identity.
    
4.  Separate expiring credentials from unused credentials; the two need different actions.
    
5.  Rotate secrets and certificates through an overlap window.
    
6.  Remediate safely: disable first, delete later.
    
7.  Schedule the sweep, then alert on the credential changes that matter.
    

![Application-object split](https://adamtheautomator.com/wp-content/uploads/publisher/3b54794735ff3c31b11dcfdd0879e8d21702cebe3fcda7bc5231a510b858254d.jpg)

## Why Service Principal Sprawl Is a Governance Problem

A service principal is the local identity your tenant issues so an application can be authorized here, and its global blueprint is the application object. The object you authenticate as is frequently a different object than the one you think you edited. Microsoft Learn states the relationship directly: “The application object is the global representation of your application for use across all tenants, and the service principal is the local representation for use in a specific tenant.”

### The Object Split That Causes Most Cleanup Mistakes

Get that split wrong and your service principal cleanup targets the wrong object while the live credential keeps working. A multi-tenant application holds one application object in its home tenant and a separate service principal in every tenant that consented to it, so a secret created on the application object authenticates somewhere you never registered anything.

### What the Vendor Telemetry Actually Says

The volume problem is real, and the vendors publishing on it say the numbers are their own telemetry. Entro Security’s H1 2025 non-human identity report counted 144 machine identities for every human one and 44 percent year-over-year growth in them; [SC Media reports the jump as 56 percent](https://www.scworld.com/news/nhis-outpace-human-accounts-by-1441). CyberArk’s earlier material put the cross-industry average near 45 to 1, and its 2026 [identity security report](https://www.paloaltonetworks.com/idira/idira-identity-security-landscape), now published under Palo Alto Networks Idira, counts 109 machine identities per human one. All vendor figures, so measure your own tenant: the ratio decides how much of this pipeline you need.

The over-privilege number you have seen deserves the same care. Microsoft’s 2024 State of Multicloud Security Report says only 3 percent of the permissions granted to workload identities are used over a year, and [Saviynt restates that as service principals](https://saviynt.com/blog/microsoft-entra-id-service-principals-governance-managing-service-principal). Hold that number loosely. The behavior it describes shows up anyway: a script that breaks on a missing permission files a support ticket within the hour, while a permission granted just in case files nothing at all.

### Four Failure Classes, Four Countermeasures

No single control covers all four, which is why the pipeline separates them. Microsoft’s [retirement documentation for service principal-less authentication](https://learn.microsoft.com/en-us/entra/identity-platform/retire-service-principal-less-authentication) closes the last exception: Entra ID blocks app authentication for non-Microsoft multitenant apps with no service principal in the tenant where they authenticate, and tenants were told to act “before March 31, 2026” to avoid disruption. Those apps are what the inventory in step one surfaces, so the pipeline doubles as the impacted-application list the change requires.

| Failure class | What it looks like in your directory | The countermeasure |
| --- | --- | --- |
| Credential sprawl | Three live secrets on one registration, oldest one years old | Ownership plus rotation with retirement |
| Over-privileged access | `Application.ReadWrite.All` granted to read a single mailbox | App role assignment review |
| Orphaned identity | A service principal with no owner and no sign-in for months | Ownership policy plus dormancy detection |
| Shadow consent | A user-consented app nobody in IT recognizes | App governance alerts and consent policy |

* * *

_**Reality Check: An ownerless service principal is a working credential that outlives the person who created it, and it will not appear in any joiner-mover-leaver process your HR system drives.**_

* * *

## Step 1: Build the Service Principal Management Inventory

Every later stage ranks, notifies, or deletes on the identifiers you collect here, so a gap here propagates.

### Connect Using the Smallest Scope Set

Start with read scopes and confirm which identity and tenant the session actually holds.

```powershell
Connect-MgGraph -Scopes 'Application.Read.All','AuditLog.Read.All'
Get-MgContext | Select-Object Account, TenantId, Scopes
```

`Connect-MgGraph` opens an interactive session for a human operator, and the [Microsoft Graph authentication documentation](https://learn.microsoft.com/en-us/powershell/microsoftgraph/authentication-commands) covers the certificate and managed identity variants for scheduled runs. The `Get-MgContext` call earns its place: a session carried over from an earlier sign-in can point at a different directory, and every count in your report becomes wrong with no error to warn you.

### Collect Both Object Collections

Pull the applications and the service principals with the properties later stages need, then write them to disk.

```powershell
$ReportPath = '<path-to-report-folder>'
New-Item -ItemType Directory -Path $ReportPath -Force | Out-Null

$Apps = Get-MgApplication -All -Property Id,AppId,DisplayName,CreatedDateTime,PasswordCredentials,KeyCredentials
$Sps  = Get-MgServicePrincipal -All -Property Id,AppId,DisplayName,AccountEnabled,ServicePrincipalType,ApplicationTemplateId,PasswordCredentials,KeyCredentials

$Apps | Select-Object DisplayName, AppId, Id, CreatedDateTime |
    Export-Csv -Path (Join-Path $ReportPath 'apps.csv') -NoTypeInformation
$Sps | Select-Object DisplayName, AppId, Id, ServicePrincipalType, AccountEnabled |
    Export-Csv -Path (Join-Path $ReportPath 'serviceprincipals.csv') -NoTypeInformation
```

Three properties carry the governance weight. `ApplicationTemplateId` identifies a service principal built from an application template and is null otherwise. `AccountEnabled` is your containment switch: setting it to `false` blocks authentication without destroying the audit trail. `PasswordCredentials` and `KeyCredentials` hold the expiry data stage four reads.

## Step 2: Flag Orphaned, Over-Privileged, and Dormant Principals

Three independent signals mark a service principal for review: nobody owns it, it holds more privilege than its job needs, or it has stopped authenticating. Each needs its own query.

### The Ownership Query Costs You Speed

Ownerless objects are the cheapest risk to find and the most expensive to ignore; nobody volunteers to clean up an identity they do not know they own.

```powershell
[array]$OwnerlessApps = Get-MgApplication -All -Property DisplayName,Id,AppId,CreatedDateTime `
    -Filter "owners/`$count eq 0" -CountVariable OwnerCount -ConsistencyLevel eventual
```

The `$count` operator inside a filter is an advanced query capability, so the call needs `ConsistencyLevel eventual` and a `CountVariable` to return anything. Escape the dollar sign inside a double-quoted filter, or use a single-quoted string, or PowerShell expands the token before Graph sees it and the query fails with a syntax error, which sends people to the permissions blade.

Microsoft’s [`servicePrincipal` reference](https://learn.microsoft.com/en-us/graph/api/resources/serviceprincipal) documents the `owners` relationship as supporting the same `/$count` filter. So the advanced query from the applications section works here too.

```powershell
[array]$OwnerlessSps = Get-MgServicePrincipal -All -Property DisplayName,Id,AppId `
    -Filter "owners/`$count eq 0" -CountVariable SpOwnerCount -ConsistencyLevel eventual
```

Use the per-object loop below only when that query times out on a large tenant.

```powershell
$OwnerlessSps = foreach ($Sp in Get-MgServicePrincipal -All -Property Id,DisplayName,AppId) {
    if (-not (Get-MgServicePrincipalOwner -ServicePrincipalId $Sp.Id -All -ErrorAction SilentlyContinue)) {
        $Sp
    }
}
```

That loop issues one Graph request per service principal, so schedule it nightly and cache the output.

### Rank the Privilege Each Principal Holds

Permission scopes tell you what an application asked for. App role assignments tell you what it holds, including grants that never passed a consent prompt, so inventory the assignments.

```powershell
$GraphSp         = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
$GraphResourceId = $GraphSp.Id
$GraphRoleMap    = @{}
foreach ($Role in $GraphSp.AppRoles) { $GraphRoleMap[$Role.Id] = $Role.Value }

$Grants = foreach ($Sp in Get-MgServicePrincipal -All -Property Id,DisplayName,AppId) {
    $Assignments = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $Sp.Id -All `
        -Filter "resourceId eq '$GraphResourceId'" -ErrorAction SilentlyContinue
    foreach ($Grant in $Assignments) {
        [PSCustomObject]@{
            ClientName  = $Sp.DisplayName
            ClientAppId = $Sp.AppId
            ClientId    = $Sp.Id
            Permission  = $GraphRoleMap[$Grant.AppRoleId]
            GrantedOn   = $Grant.CreatedDateTime
        }
    }
}
$Grants | Sort-Object ClientName, Permission |
    Export-Csv -Path (Join-Path $ReportPath 'app-only-grants.csv') -NoTypeInformation
```

Two details keep this affordable: build the role map once from the resource service principal, and filter on `resourceId` so the tenant returns Graph assignments only.

### Detect Dormancy From Sign-In Data

[Service principal sign-ins are a separate log class from user sign-ins](https://adamtheautomator.com/taming-ai-tool-sprawl-powershell-guide-auditing/), and the [service principal sign-in log reference](https://learn.microsoft.com/en-us/entra/identity/monitoring-health/concept-service-principal-sign-ins) is the field-level source for what each record carries. The v1.0 `signIn` resource carries no service-principal fields, so read dormancy from the [`servicePrincipalSignInActivity`](https://learn.microsoft.com/en-us/graph/api/resources/serviceprincipalsigninactivity) report instead.

```powershell
$Since = (Get-Date).AddDays(-90)

$Activity = Invoke-MgGraphRequest -Method GET `
    -Uri 'https://graph.microsoft.com/beta/reports/servicePrincipalSignInActivities?$top=999'

# Index the report by appId once. The servicePrincipalSignInActivity resource records
# activity in four flows, and lastSignInActivity is the most recent of them.
$LastSeenByAppId = @{}
foreach ($Row in $Activity.value) {
    $Timestamps = @(
        $Row.lastSignInActivity.lastSignInDateTime
        $Row.lastSignInActivity.lastNonInteractiveSignInDateTime
        $Row.lastSignInActivity.lastSuccessfulSignInDateTime
        $Row.applicationAuthenticationClientSignInActivity.lastSignInDateTime
        $Row.applicationAuthenticationClientSignInActivity.lastNonInteractiveSignInDateTime
        $Row.applicationAuthenticationClientSignInActivity.lastSuccessfulSignInDateTime
        $Row.delegatedClientSignInActivity.lastSignInDateTime
        $Row.delegatedClientSignInActivity.lastNonInteractiveSignInDateTime
        $Row.delegatedClientSignInActivity.lastSuccessfulSignInDateTime
    ) | Where-Object { $_ } | ForEach-Object { [datetime]$_ }
    $LastSeenByAppId[$Row.appId] = ($Timestamps | Sort-Object -Descending | Select-Object -First 1)
}

$DormantSps = $Sps | Where-Object {
    $_.ServicePrincipalType -eq 'Application' -and
    (-not $LastSeenByAppId.ContainsKey($_.AppId) -or $LastSeenByAppId[$_.AppId] -lt $Since)
}
$DormantSps | Select-Object DisplayName, AppId, Id |
    Export-Csv -Path (Join-Path $ReportPath 'dormant-principals.csv') -NoTypeInformation
```

Pulling the whole report into memory works for a small tenant and collapses on a large one. For a 90-day dormancy window at that scale, send the service principal sign-in logs to a [Log Analytics workspace](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/log-analytics-overview) instead. Otherwise your dormancy window becomes whatever default log retention allows.

## Step 3: Enforce Ownership and Accountability

Without an owner, an identity has no reviewer, so its renewal goes unapproved and its leaked credential goes unnoticed.

### Why One Owner Is Not an Owner Policy

Two owners per application is the minimum that survives a resignation. But owners leave, change teams, or hand off to a contractor mailbox, and the service principal joins the orphaned list you’re trying to shrink. Keep at least one of those owners a group, and record the owner count in every report so drift shows before the next audit.

```powershell
$OwnerRef = @{ '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$OwnerObjectId" }

New-MgApplicationOwnerByRef -ApplicationId $App.Id -BodyParameter $OwnerRef
New-MgServicePrincipalOwnerByRef -ServicePrincipalId $Sp.Id -BodyParameter $OwnerRef
```

Both the [`New-MgApplicationOwnerByRef`](https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.applications/new-mgapplicationownerbyref) and [`New-MgServicePrincipalOwnerByRef`](https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.applications/new-mgserviceprincipalownerbyref) cmdlets write to the object you name, and success depends on that object’s type and your rights over it. Filter `ApplicationTemplateId` rows out of the remediation queue and route vendor-managed applications to the publisher.

## Step 4: Separate Expiring Credentials from Unused Credentials

Expired credential detection and unused-credential detection are different findings, and conflating them produces wrong remediation. An expiry date is a calendar fact you read off the object, and a credential that stopped being used is a telemetry fact only the Entra recommendations engine can give you.

### Scan Expiry From the Object

Start with the calendar check, because it is cheap and it catches outages nobody schedules for.

```powershell
$Threshold = (Get-Date).AddDays(60)
$Expiring  = foreach ($App in $Apps) {
    foreach ($Secret in $App.PasswordCredentials) {
        if ($null -eq $Secret.EndDateTime) {
            [PSCustomObject]@{
                ObjectType    = 'Application'
                DisplayName   = $App.DisplayName
                ObjectId      = $App.Id
                SecretName    = $Secret.DisplayName
                KeyId         = $Secret.KeyId
                EndDateTime   = $null
                Bucket        = 'NeverExpires'
                DaysRemaining = $null
            }
        }
        elseif ($Secret.EndDateTime -le $Threshold) {
            [PSCustomObject]@{
                ObjectType    = 'Application'
                DisplayName   = $App.DisplayName
                ObjectId      = $App.Id
                SecretName    = $Secret.DisplayName
                KeyId         = $Secret.KeyId
                EndDateTime   = $Secret.EndDateTime
                Bucket        = 'Expiring'
                DaysRemaining = [int]($Secret.EndDateTime - (Get-Date)).TotalDays
            }
        }
    }
}
$Expiring | Export-Csv -Path (Join-Path $ReportPath 'expiring-credentials.csv') -NoTypeInformation
```

The `$null -eq $Secret.EndDateTime` test runs first on purpose: `$null -le [datetime]` evaluates to `$true`, so a direct threshold comparison files a never-expiring credential under `Expiring` with a nonsense day count. The `Bucket` column separates the two, and `NeverExpires` is the severe one.

### Ask the Recommendations Engine About Use

Reading expiry dates and calling the results unused is the mistake this stage prevents.

| Entra recommendation | API name | What triggers it |
| --- | --- | --- |
| Remove unused applications | `staleApps` | Application unused for 90 days |
| Remove unused credentials from applications | `staleAppCreds` | Credential unused for 30 days |
| Renew expiring application credentials | `applicationCredentialExpiry` | Application credential expiring within 30 days |
| Renew expiring service principal credentials | `servicePrincipalKeyExpiry` | Service principal credential expiring within 30 days |

The [documentation for the unused-credentials recommendation](https://learn.microsoft.com/en-us/entra/identity/monitoring-health/recommendation-remove-unused-credential-from-apps) defines the trigger precisely: “A credential is considered unused if: It has not been used in the past 30 days.” It also draws a boundary that spares you a false alarm: expired credentials are excluded from the impacted resources list. An expired secret nothing is using belongs in the cleanup backlog; a live unused credential belongs in a revocation decision. Report them as separate findings with separate owners.

* * *

_**Key Insight: A 30-day no-use verdict is the strongest evidence you can get that a credential is safe to remove, and it comes only from the recommendations engine.**_

* * *

## Step 5: Rotate Secrets and Certificates Without Downtime

Rotation is a sequencing problem before it’s a scripting problem. Generate the replacement, update every consumer, confirm the new credential authenticates, then retire the old one. A swap in the other order breaks the workload and teaches the team to fear credential hygiene.

### Overlap, Then Retire

Client secrets used for app-only authentication live on the application object, so the rotation path takes the application object ID from your inventory rather than the service principal object ID. The wrong one returns a 404 that reads like a permissions failure and sends people hunting for a role they already hold.

```powershell
$AppObjectId = '<application-object-id>'

$App = Get-MgApplication -ApplicationId $AppObjectId
$PreviousKeyId = $App.PasswordCredentials |
    Sort-Object EndDateTime | Select-Object -First 1 -ExpandProperty KeyId

$NewSecret = Add-MgApplicationPassword -ApplicationId $AppObjectId -PasswordCredential @{
    DisplayName = "Rotated $(Get-Date -Format 'yyyyMMdd')"
    EndDateTime = (Get-Date).AddMonths(6)
}
```

Microsoft Learn’s [`addPassword` reference](https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.applications/add-mgapplicationpassword) is blunt about the handling requirement: “There is no way to retrieve this password in the future.” `$NewSecret.SecretText`, the key ID, and the expiry exist in that single response and never again, so write the secret straight into [Azure Key Vault](https://learn.microsoft.com/en-us/azure/key-vault/general/overview) and keep only the key ID and expiry in your report. The [`Set-AzKeyVaultSecret`](https://adamtheautomator.com/azure-keyvault-secrets-powershell/) call below comes from the `Az.KeyVault` module, so install it and connect to the vault’s subscription first. Replace `<key-vault-name>` with the vault name rather than a URI; the identity running the rotation needs write access to that vault.

```powershell
# Key Vault secret names accept only letters, digits, and hyphens.
$SecretName = (($App.DisplayName -replace '[^0-9a-zA-Z-]', '-') -replace '-{2,}', '-').Trim('-')

Set-AzKeyVaultSecret -VaultName '<key-vault-name>' `
    -Name "$SecretName-clientid" `
    -SecretValue (ConvertTo-SecureString $NewSecret.SecretText -AsPlainText -Force)
```

The slug matters because Key Vault rejects any name outside `^[0-9a-zA-Z-]+$`, and most display names carry a space, a parenthesis, or a dot.

Both secrets are valid now, which is the overlap window. Update the consumer, run its workload once, and check the key ID in the sign-in log before removing anything.

```powershell
Remove-MgApplicationPassword -ApplicationId $AppObjectId -KeyId $PreviousKeyId
```

Cap the overlap at something short, or you recreate the sprawl the rotation was meant to solve.

## Step 6: Remediate Safely by Disabling Before Deleting

Deletion is the last resort and the only irreversible step, so it follows a disable period that preserves the ability to investigate.

### Verify Before You Delete

Disable first, and confirm the identity stopped authenticating before you consider removal.

```powershell
Update-MgServicePrincipal -ServicePrincipalId $Sp.Id -AccountEnabled:$false
```

Read the object back to confirm the change landed.

```powershell
Get-MgServicePrincipal -ServicePrincipalId $Sp.Id |
    Select-Object DisplayName, AccountEnabled
```

Setting `accountEnabled` to `false` stops the identity authenticating without destroying the object, so the exposure ends and the audit trail stays. Leave it disabled for a full reporting cycle. A service principal nobody complains about after 30 days blocked is one nobody needs.

* * *

_**Warning: _**`Remove-MgServicePrincipal`**_ and the credential removal cmdlets change your tenant. Run them against a test application first, and confirm the previous credential no longer appears in the sign-in log before you remove it from a production registration.**_

* * *

Removal inside the grace period is reversible: Entra ID restores a deleted application registration together with its service principal, per its [deletion and recovery guidance](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/delete-recover-faq). The 30-day soft-delete window is that grace period; permanent deletion from the deleted items collection is the step nothing undoes.

## Step 7: Schedule the Sweep and Alert on Credential Changes

Credential lifecycle automation delivers its value on a schedule, and the scheduled version can’t hold a stored secret.

### Wire the Identity Into a Runbook

A system-assigned managed identity on an Azure Automation account gives the runbook a service principal with no client secret in the design.

```powershell
$GraphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
$RoleId  = ($GraphSp.AppRoles |
    Where-Object { $_.Value -eq 'Application.ReadWrite.All' -and $_.AllowedMemberTypes -contains 'Application' }).Id

New-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $GraphSp.Id -BodyParameter @{
    PrincipalId = '<automation-account-managed-identity-principal-id>'
    ResourceId  = $GraphSp.Id
    AppRoleId   = $RoleId
}
```

Grant the same identity `DirectoryRecommendations.Read.All` and `AuditLog.Read.All` the same way, then authenticate inside the runbook without a credential at all.

```powershell
Connect-MgGraph -Identity
```

Import `Microsoft.Graph.Authentication` and the sub-modules the runbook calls from the Automation module gallery, matched to the runbook’s runtime, and attach a schedule: nightly for the inventory, weekly for the notification pass. Connecting this way keeps the governance tool from becoming the highest-value credential in the tenant.

### From Report to Alert

A scheduled runbook covers the sweep, and event-driven remediation covers the single object. The [directory audit log](https://learn.microsoft.com/en-us/entra/identity/monitoring-health/reference-audit-activities) names the events worth alerting on.

*   `Add service principal credentials` and `Remove service principal credentials` fire whenever a secret or certificate is minted or removed, which makes an unexpected entry the highest-signal alert here.
    
*   `Add app role assignment to service principal` marks a privilege grant, including one that never passed a consent prompt.
    
*   `Add owner to application` and `Remove owner from service principal` are how an application becomes ownerless, one change at a time.
    

Route Entra diagnostic settings to a Log Analytics workspace, query the `AuditLogs` table on `ActivityDisplayName` and `InitiatedBy`, and let an [Azure Logic App](https://learn.microsoft.com/en-us/azure/logic-apps/logic-apps-overview) with its own managed identity disable a flagged service principal when a policy fires. Entra ID automation runs the sweep, and the logic app handles the single-object response.

Entra ID emails SAML certificate expiry warnings, and recommendation notifications can cover app credentials too, but the runbook report is the mechanism you control. The `Bucket` and `DaysRemaining` columns from step four drive this pass: mail the owning group at the 60, 30, and 7-day bands, record what you sent so the next run does not repeat it, and give the runbook `Mail.Send` on the sender mailbox.

```powershell
$Expiring = Import-Csv -Path (Join-Path $ReportPath 'expiring-credentials.csv')

foreach ($Row in $Expiring | Where-Object Bucket -eq 'Expiring') {
    $Days = [int]$Row.DaysRemaining
    $Band = $null
    if     ($Days -le 7)  { $Band = '7-day' }
    elseif ($Days -le 30) { $Band = '30-day' }
    else                  { $Band = '60-day' }

    Send-MgUserMail -UserId '<sender-mailbox>' -BodyParameter @{
        Message = @{
            Subject      = "Credential $Band notice: $($Row.DisplayName)"
            Body         = @{
                ContentType = 'Text'
                Content     = "$($Row.SecretName) on $($Row.DisplayName) expires in $Days days. Rotate it before the overlap window closes."
            }
            ToRecipients = @(@{ EmailAddress = @{ Address = '<owner-address>' } })
        }
    }
}
```

![Overlap window rotation](https://adamtheautomator.com/wp-content/uploads/publisher/31a8d570cc331b600dad3cc44288cfbe9fa21f10d4da038da6d9d545328f735e.jpg)

## Frequently Asked Questions

These come up every time this pipeline gets handed to a second team.

### Do I Need a Paid License for Any of This?

Inventory, ownership, rotation, and disablement run on Microsoft Graph permissions in any Entra ID tenant. Two capabilities depend on licensing: unused-credential detection needs the [recommendations engine](https://learn.microsoft.com/en-us/entra/identity/monitoring-health/overview-recommendations), and [Graph sign-in log retrieval](https://learn.microsoft.com/en-us/graph/api/resources/signin) requires an Entra ID P1 or P2 license. Run the other stages regardless, and record dormancy detection as unavailable rather than reporting zero principals.

### What Is the Difference Between an Expiring and an Unused Credential?

An expiring credential has an `EndDateTime` inside your warning window. An unused credential has not authenticated in 30 days according to the recommendations engine. The first is a maintenance task with a deadline; the second is a revocation decision an owner has to approve.

### Can I Just Delete Every Service Principal With No Owner?

Not without checking sign-in activity first; ownership gaps and dead identities are different populations that only partly overlap. Disable the object, wait a cycle, and confirm no workload breaks. A service principal an undocumented overnight job depends on looks identical to an abandoned one until you switch it off.

### Should I Use a Certificate Instead of a Secret?

Yes, when the workload can hold a private key safely. [Using a certificate instead of a secret](https://adamtheautomator.com/service-principal-certificates/) removes the string that ends up in a configuration file or a chat message, and certificates live in the [`keyCredentials` collection on the service principal object](https://learn.microsoft.com/en-us/graph/api/resources/serviceprincipal), separate from the `passwordCredentials` that hold secrets. Keep the same overlap sequencing, because a certificate swap in one step carries the same outage risk.

### How Do I Stop This From Coming Back?

Put the two-owner policy in the same change process that creates the registration, and make the nightly report visible to the workload’s owning team. Governance that lives in a quarterly review loses to automation that lives in the deployment pipeline.

## Wrapping Up

Service principal sprawl is an ownership gap that produces credentials nobody is accountable for, and the [Microsoft Graph cmdlets](https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.applications/) supply everything a service principal management program needs: inventory to establish a denominator, ownership to create accountability, and remediation commands that disable before they delete.

Run the pipeline weekly for a quarter and the reports become comparable: `apps.csv` and `serviceprincipals.csv` from this run against the previous one show whether the ownerless count is moving, and `expiring-credentials.csv` shows whether rotations closed. Two things stay manual: template-instantiated applications filtered out by `ApplicationTemplateId` still need a request to the publisher, and an owner who declines a renewal still needs a decision from whoever owns the workload. The report tells you which identity is next; it does not tell you who argues for it.

Share this article

[Share on X](https://twitter.com/intent/tweet?url=https%3A%2F%2Fadamtheautomator.com%2Fstop-service-principal-sprawl-entra-id-powershell%2F&text=Stop%20Service%20Principal%20Sprawl%20in%20Entra%20ID%20with%20PowerShell)[Share on Facebook](https://www.facebook.com/sharer/sharer.php?u=https%3A%2F%2Fadamtheautomator.com%2Fstop-service-principal-sprawl-entra-id-powershell%2F)[Share on LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fadamtheautomator.com%2Fstop-service-principal-sprawl-entra-id-powershell%2F)

## Related Posts

![](https://adamtheautomator.com/wp-content/uploads/2026/06/featured_image-13.png)

### [Taming AI Tool Sprawl: A PowerShell Guide to Auditing and Governing Unauthorized AI Applications](/taming-ai-tool-sprawl-powershell-guide-auditing/)

Detect and govern unauthorized AI tools with PowerShell, Microsoft Graph, Entra ID, and Defender for Cloud Apps.

![](https://adamtheautomator.com/wp-content/uploads/2026/08/featured_image.webp)

### [Entra PIM vs. Delinea vs. CyberArk: Don’t Buy the Wrong PAM](/entra-pim-vs-delinea-cyberark/)

Entra PIM grants roles; Delinea and CyberArk vault credentials. Compare scope, session recording, audit evidence, and three-year cost.

![](https://adamtheautomator.com/wp-content/uploads/2026/07/featured_image-2.png)

### [Deploy PAWs with Microsoft Entra PIM](/deploy-paws-microsoft-entra-pim/)

Harden privileged access by pairing Microsoft Entra PIM with PAWs, Intune compliance, and Conditional Access so admin roles activate only from trusted devices.

## Categories

*   [IT Ops](/category/it-ops/)
*   [Cloud](/category/cloud/)
*   [DevOps](/category/devops/)
*   [Home Ops](/category/home-ops/)
*   [Information Security](/category/infosec/)
*   [Software Development](/category/software-development/)

## Site

*   [Home](/)
*   [Tutorials](/tutorials/)
*   [Instructors](/author/)
*   [Advertising](/advertising/)
*   [Recommended Resources](/resources/)
*   [About Adam](/about-adam/)

Copyright 2026© ATA Learning | [Privacy Policy](/privacy/)
