Audit the OAuth app estate, create permission governance policies, implement admin consent workflows, conduct access reviews, set up dormant app cleanup, and build compliance dashboards and audit reports.
App compliance and lifecycle management ensures OAuth apps are continuously monitored, reviewed, and managed throughout their lifecycle. from initial consent through periodic review to eventual decommissioning. This lab covers building compliance policies, implementing approval workflows, conducting periodic access reviews, and managing the app lifecycle to maintain a clean and secure app estate.
An organisation discovers 340 OAuth apps in their tenant but only 120 are approved through IT governance. The remaining 220 were consented by individual users without security review. The compliance team mandates quarterly access reviews, app owner attestation, and automatic decommissioning of unused apps. The security team must build an app compliance program from scratch.
Without lifecycle management, OAuth apps accumulate like technical debt. Dormant apps with high permissions are silent attack surfaces. Former employees'' consented apps remain active. Acquired companies bring unknown app estates. A structured lifecycle management program keeps the app estate clean, compliant, and defensible.
Microsoft.Graph module v2.x (Install-Module Microsoft.Graph -Scope CurrentUser)Application.Read.All, DelegatedPermissionGrant.ReadWrite.All, AppRoleAssignment.ReadWrite.All, AuditLog.Read.AllBefore you can build a compliance program, you need a complete inventory of every OAuth app in your tenant - including who consented to it, what permissions it holds, whether the publisher is verified, and when it was last used. The PowerShell script below exports the full app inventory from Microsoft Graph, categorises each app by risk level based on permission scope and publisher verification status, and produces a structured CSV for compliance review.
# WHAT: Export and categorise the full OAuth app estate with risk-based classification
# WHY: You cannot govern what you cannot see - this script builds a complete inventory
# with automatic risk scoring based on permissions, publisher status, and certification
# REQUIRES: Microsoft.Graph module v2.x
# Scopes: Application.Read.All, DelegatedPermissionGrant.ReadWrite.All
Connect-MgGraph -Scopes "Application.Read.All","DelegatedPermissionGrant.ReadWrite.All"
# Retrieve all service principals (represents apps consented in the tenant)
$servicePrincipals = Get-MgServicePrincipal -All -Property `
Id,AppId,DisplayName,PublisherName,VerifiedPublisher,`
AccountEnabled,AppRoles,Oauth2PermissionScopes,`
SignInAudience,CreatedDateTime,Tags
Write-Host "Found $($servicePrincipals.Count) service principals" -ForegroundColor Cyan
# Define high-risk Graph permission scopes that require admin governance
$highRiskScopes = @(
"Mail.ReadWrite", "Mail.Read", "Files.ReadWrite.All",
"User.ReadWrite.All", "Directory.ReadWrite.All",
"Sites.ReadWrite.All", "MailboxSettings.ReadWrite"
)
# Build the compliance inventory
$inventory = foreach ($sp in $servicePrincipals) {
# Get all delegated permission grants for this app
$grants = Get-MgServicePrincipalOauth2PermissionGrant `
-ServicePrincipalId $sp.Id -ErrorAction SilentlyContinue
$allScopes = ($grants | ForEach-Object { $_.Scope -split " " }) | Sort-Object -Unique
$hasHighRisk = ($allScopes | Where-Object { $_ -in $highRiskScopes }).Count -gt 0
# Determine risk category
$riskLevel = switch ($true) {
($hasHighRisk -and -not $sp.VerifiedPublisher.DisplayName) { "Critical" }
($hasHighRisk) { "High" }
(-not $sp.VerifiedPublisher.DisplayName) { "Medium" }
default { "Low" }
}
[PSCustomObject]@{
AppName = $sp.DisplayName
AppId = $sp.AppId
Publisher = $sp.PublisherName
PublisherVerified = [bool]$sp.VerifiedPublisher.DisplayName
Enabled = $sp.AccountEnabled
RiskLevel = $riskLevel
HighRiskScopes = ($allScopes | Where-Object { $_ -in $highRiskScopes }) -join "; "
AllScopes = $allScopes -join "; "
CreatedDate = $sp.CreatedDateTime
SignInAudience = $sp.SignInAudience
}
}
# Export and display summary
$inventory | Export-Csv "AppGovernance-Inventory-$(Get-Date -Format 'yyyyMMdd').csv" -NoTypeInformation
$inventory | Group-Object RiskLevel | Select-Object Name, Count | Format-Table -AutoSize
Write-Host "Inventory exported successfully." -ForegroundColor GreenAdmin consent workflows prevent users from granting apps access to organisational data without security review. But you also need visibility into the permissions that have already been granted. The KQL query below audits every OAuth permission grant across your entire tenant - showing which apps hold which permissions, who consented, and whether grants were admin-level or user-level - giving you the evidence needed to enforce the governance policies you defined in Step 2.
// WHAT: Comprehensive audit of all OAuth permission grants across the tenant
// WHY: Before enforcing admin consent workflows, you need a complete picture of
// existing grants - which apps have what access, who approved it, and whether
// any grants bypass your new governance policies
// TABLE: AuditLogs - captures all consent grant operations in Entra ID
// KEY FIELDS:
// OperationName - "Consent to application" or "Add delegated permission grant"
// TargetResources - the app that received the permission grant
// InitiatedBy - user or admin who performed the consent
// AdditionalDetails - contains the specific permission scopes granted
// OUTPUT: Every permission grant in the last 90 days with grantor identity, scope
// details, and consent type (admin vs user) for compliance review
// All consent operations in the last 90 days
AuditLogs
| where TimeGenerated > ago(90d)
| where OperationName in (
"Consent to application",
"Add delegated permission grant",
"Add app role assignment to service principal"
)
| extend AppName = tostring(TargetResources[0].displayName),
AppId = tostring(TargetResources[0].id),
ConsentedBy = tostring(InitiatedBy.user.userPrincipalName),
ConsentType = iff(OperationName == "Add app role assignment to service principal",
"Application (Admin)", "Delegated (User)"),
Scopes = tostring(parse_json(
tostring(TargetResources[0].modifiedProperties))[0].newValue)
| project TimeGenerated, AppName, AppId, ConsentedBy, ConsentType, Scopes, OperationName
| order by TimeGenerated desc
// Summary: permission grants by consent type and risk level
// AuditLogs
// | where TimeGenerated > ago(90d)
// | where OperationName has "consent" or OperationName has "permission grant"
// | extend ConsentType = iff(OperationName has "app role", "Admin", "User")
// | summarize GrantCount = count() by ConsentType, bin(TimeGenerated, 7d)
// | render timechartApp owner attestation ensures every approved app has an accountable business owner who periodically confirms the app is still needed and its permissions remain appropriate. A critical part of attestation is credential hygiene - tracking when app secrets and certificates expire, flagging apps with credentials older than your rotation policy, and ensuring no app operates with stale or untracked credentials. The PowerShell script below audits credential expiration across all app registrations.
# WHAT: Audit credential expiration and rotation compliance across all app registrations
# WHY: Stale credentials are a top attack vector - expired secrets may indicate
# abandoned apps, while secrets older than 180 days violate most compliance
# frameworks (SOC 2, ISO 27001 recommend 90β180 day rotation)
# REQUIRES: Microsoft.Graph module v2.x
# Scopes: Application.Read.All
Connect-MgGraph -Scopes "Application.Read.All"
# Retrieve all app registrations with credential details
$apps = Get-MgApplication -All -Property Id,AppId,DisplayName,`
PasswordCredentials,KeyCredentials,CreatedDateTime
$today = Get-Date
$rotationPolicyDays = 180 # Max credential age before non-compliance
# Analyse each app's credential status
$credentialReport = foreach ($app in $apps) {
$allCreds = @()
# Check client secrets (password credentials)
foreach ($secret in $app.PasswordCredentials) {
$age = ($today - $secret.StartDateTime).Days
$daysToExpiry = ($secret.EndDateTime - $today).Days
$allCreds += [PSCustomObject]@{
Type = "ClientSecret"
KeyId = $secret.KeyId
AgeDays = $age
ExpiresIn = $daysToExpiry
Status = if ($daysToExpiry -lt 0) { "EXPIRED" }
elseif ($daysToExpiry -lt 30) { "EXPIRING-SOON" }
elseif ($age -gt $rotationPolicyDays) { "ROTATION-OVERDUE" }
else { "Compliant" }
}
}
# Check certificates (key credentials)
foreach ($cert in $app.KeyCredentials) {
$age = ($today - $cert.StartDateTime).Days
$daysToExpiry = ($cert.EndDateTime - $today).Days
$allCreds += [PSCustomObject]@{
Type = "Certificate"
KeyId = $cert.KeyId
AgeDays = $age
ExpiresIn = $daysToExpiry
Status = if ($daysToExpiry -lt 0) { "EXPIRED" }
elseif ($daysToExpiry -lt 30) { "EXPIRING-SOON" }
elseif ($age -gt $rotationPolicyDays) { "ROTATION-OVERDUE" }
else { "Compliant" }
}
}
# Flag apps with no credentials (may be unused) or non-compliant credentials
$nonCompliant = $allCreds | Where-Object { $_.Status -ne "Compliant" }
[PSCustomObject]@{
AppName = $app.DisplayName
AppId = $app.AppId
TotalCreds = $allCreds.Count
Expired = ($allCreds | Where-Object { $_.Status -eq "EXPIRED" }).Count
ExpiringSoon = ($allCreds | Where-Object { $_.Status -eq "EXPIRING-SOON" }).Count
RotationOverdue = ($allCreds | Where-Object { $_.Status -eq "ROTATION-OVERDUE" }).Count
WorstStatus = if ($nonCompliant) { ($nonCompliant | Sort-Object ExpiresIn |
Select-Object -First 1).Status } else { "Compliant" }
NoCreds = $allCreds.Count -eq 0
}
}
# Export and display summary
$credentialReport | Export-Csv "CredentialRotation-Audit-$(Get-Date -Format 'yyyyMMdd').csv" -NoTypeInformation
$credentialReport | Group-Object WorstStatus | Select-Object Name, Count | Format-Table -AutoSize
Write-Host "`nApps with no credentials (potentially unused): $(($credentialReport | Where-Object NoCreds).Count)" -ForegroundColor YellowLifecycle management requires proactive identification of apps that are no longer serving a business purpose. Dormant apps - those with valid permissions but no recent API activity - are particularly dangerous because they represent unmonitored access pathways that an attacker could exploit. The KQL query below identifies apps with no sign-in or API activity in the last 90 days while still holding active permission grants, producing a prioritised cleanup list.
// WHAT: Identify dormant OAuth apps with active permissions but no recent API activity
// WHY: Dormant apps are silent attack surfaces - they hold valid permissions and tokens
// but are not monitored because no one is actively using them. Attackers target
// exactly these apps because compromise goes unnoticed for months
// TABLES: AADServicePrincipalSignInLogs - service principal authentication events
// CloudAppEvents - app API activity against Microsoft 365 services
// OUTPUT: Apps with no sign-in or API activity in 90+ days, ranked by permission
// privilege level - these are candidates for decommissioning
// Step 1: Find all apps that DID authenticate in the last 90 days
let ActiveApps = AADServicePrincipalSignInLogs
| where TimeGenerated > ago(90d)
| where ResultType == 0
| distinct AppId;
// Step 2: Find all apps that made API calls in the last 90 days
let ActiveAPIApps = CloudAppEvents
| where Timestamp > ago(90d)
| distinct Application;
// Step 3: Find apps in AuditLogs that received consent but are NOT in the active sets
AuditLogs
| where TimeGenerated > ago(365d)
| where OperationName in (
"Consent to application",
"Add delegated permission grant",
"Add app role assignment to service principal"
)
| extend AppName = tostring(TargetResources[0].displayName),
AppId = tostring(TargetResources[0].id),
ConsentedBy = tostring(InitiatedBy.user.userPrincipalName),
ConsentDate = TimeGenerated
| summarize LastConsent = max(ConsentDate),
ConsentedBy = any(ConsentedBy)
by AppName, AppId
| where AppId !in (ActiveApps)
and AppName !in (ActiveAPIApps)
| extend DormantDays = datetime_diff('day', now(), LastConsent)
| where DormantDays > 90
| project AppName, AppId, LastConsent, DormantDays, ConsentedBy
| order by DormantDays descCompliance reporting transforms your app governance activities into auditable evidence that satisfies regulatory frameworks like SOC 2, ISO 27001, and NIST CSF. The PowerShell script below generates a comprehensive quarterly compliance report covering app inventory status, permission governance metrics, attestation compliance rates, and dormant app cleanup progress - formatted for direct presentation to compliance committees and external auditors.
# WHAT: Generate a quarterly App Governance compliance report for audit
# WHY: Auditors require documented evidence of ongoing app governance - this script
# produces a structured report covering inventory, risk distribution, attestation
# rates, dormant app cleanup, and permission governance metrics
# REQUIRES: Microsoft.Graph module v2.x
# Scopes: Application.Read.All, AuditLog.Read.All
Connect-MgGraph -Scopes "Application.Read.All","AuditLog.Read.All"
$reportDate = Get-Date -Format "yyyy-MM-dd"
$quarterLabel = "Q" + [math]::Ceiling((Get-Date).Month / 3) + " " + (Get-Date).Year
# --- Section 1: App Inventory Summary ---
$allSPs = Get-MgServicePrincipal -All -Property Id,DisplayName,`
AccountEnabled,VerifiedPublisher,AppId
$inventorySummary = [PSCustomObject]@{
TotalApps = $allSPs.Count
EnabledApps = ($allSPs | Where-Object AccountEnabled).Count
DisabledApps = ($allSPs | Where-Object { -not $_.AccountEnabled }).Count
VerifiedPublisherApps = ($allSPs | Where-Object { $_.VerifiedPublisher.DisplayName }).Count
UnverifiedApps = ($allSPs | Where-Object { -not $_.VerifiedPublisher.DisplayName }).Count
}
# --- Section 2: Permission Governance Metrics ---
$highRiskScopes = @("Mail.ReadWrite","Files.ReadWrite.All","User.ReadWrite.All",
"Directory.ReadWrite.All","Sites.ReadWrite.All")
$appsWithHighPerms = 0
foreach ($sp in $allSPs | Select-Object -First 200) {
$grants = Get-MgServicePrincipalOauth2PermissionGrant `
-ServicePrincipalId $sp.Id -ErrorAction SilentlyContinue
$scopes = ($grants | ForEach-Object { $_.Scope -split " " }) | Sort-Object -Unique
if ($scopes | Where-Object { $_ -in $highRiskScopes }) { $appsWithHighPerms++ }
}
# --- Section 3: Consent Activity (last 90 days) ---
# Note: Consent audit data is queried separately via KQL/Advanced Hunting
# --- Section 4: Build the report ---
$report = @"
========================================
APP GOVERNANCE COMPLIANCE REPORT
$quarterLabel | Generated: $reportDate
========================================
1. APP INVENTORY
Total apps in tenant: $($inventorySummary.TotalApps)
Enabled / Active: $($inventorySummary.EnabledApps)
Disabled / Blocked: $($inventorySummary.DisabledApps)
Verified publisher: $($inventorySummary.VerifiedPublisherApps)
Unverified publisher: $($inventorySummary.UnverifiedApps)
2. PERMISSION GOVERNANCE
Apps with high-risk perms: $appsWithHighPerms
High-risk scope coverage: $($highRiskScopes -join ', ')
3. COMPLIANCE CONTROL MAPPING
SOC 2 CC6.1 (Logical Access) - Admin consent workflow enabled
SOC 2 CC6.3 (Access Removal) - Dormant app cleanup active
ISO 27001 A.9.2.5 (Review) - Quarterly access review scheduled
ISO 27001 A.9.4.1 (Restrict) - Publisher verification enforced
NIST CSF PR.AC-1 (Identities) - App inventory maintained
4. RECOMMENDATIONS
- Review $($inventorySummary.UnverifiedApps) unverified publisher apps
- Investigate $appsWithHighPerms apps with high-risk permissions
- Complete attestation cycle for current quarter
"@
# Save report
$reportPath = "AppGov-ComplianceReport-$quarterLabel-$reportDate.txt"
$report | Out-File $reportPath -Encoding UTF8
Write-Host $report
Write-Host "`nReport saved to: $reportPath" -ForegroundColor Green| Resource | Description |
|---|---|
| App Governance Overview | Manage and govern OAuth apps with App Governance in Defender for Cloud Apps |
| Admin Consent Workflow | Configure the admin consent workflow in Microsoft Entra ID |
| App Credential Management | Best practices for managing application credentials and certificate rotation |
| Microsoft Graph - Service Principals | Programmatically manage service principals, permissions, and consent grants |
| Entra ID Access Reviews | Plan and deploy access reviews for applications and service principals |
| AuditLogs Table Reference | Schema reference for Entra ID audit logs used in consent and permission queries |