Entra Default User Permissions: The Open App Registration Problem

In your Entra ID tenant right now, every one of your users can register an application, assign it Mail.Read permission, consent to it themselves, and start reading their own email programmatically with no admin approval, no alert, and no audit flag. That is the default configuration Microsoft ships. This article asks: who has already done this, what did they access, and how do you find out?

This is not a vulnerability in the traditional sense. Microsoft did not make a mistake. They made a deliberate product decision to make application development accessible to every user in a tenant, and that decision ships enabled by default in every Entra ID tenant on the planet free tier included. The problem is not the feature itself. The problem is that most organizations have no idea it exists, no visibility into how it has been used, and no detection coverage for when it gets abused.

Before we get into the lab there is a lot of foundational knowledge worth building. OAuth 2.0 and the Microsoft identity platform have a specific vocabulary and a specific set of trust models that determine exactly what an attacker or insider can and cannot do. Understanding those models is what makes this research meaningful rather than just a list of commands to copy-paste.


Understanding the Foundation

What an App Registration Actually Is

When a developer wants to build something that interacts with Microsoft services reading email, accessing SharePoint files, calling Teams APIs, querying user data the first step is creating an app registration in Entra ID. Think of it as registering the application’s identity in Microsoft’s directory, the same way a new employee gets an account on their first day.

An app registration creates two things. First, the application object the global definition of the application containing its name, redirect URIs, required permissions, and any credentials like certificates or secrets. Second, when a user or admin consents to the application, a service principal is created in the tenant representing that app’s presence there. The application object is the blueprint. The service principal is the installed instance.

Every app registration gets a unique Application (Client) ID and a Directory (Tenant) ID. These are the identifiers the app uses when it authenticates to Entra ID and requests tokens.

Here is what almost nobody in a security role has noticed: any standard user in a tenant can create an app registration by default. No admin role required. No approval process. The user goes to portal.azure.com, finds App Registrations, and clicks New Registration. The tenant setting controlling this is called “Users can register applications” and it ships set to Yes in every new tenant.

OAuth 2.0 The Protocol That Makes This Possible

OAuth 2.0 is the authorization framework used by virtually every modern cloud service. The entire attack surface this article documents lives inside how OAuth handles permissions and consent, so understanding it properly matters.

OAuth defines four roles. The resource owner is the user who owns the data their mailbox, their files, their calendar. The client is the application requesting access to that data. The resource server is the API holding the data in Microsoft’s world, this is Microsoft Graph. The authorization server is Entra ID, the system that issues access tokens after the user gives permission.

The flow works like this. The user clicks something in the application that requires access to their data. The application redirects the user to Entra ID with three pieces of information: which app is asking (the client ID), what it wants access to (the requested scopes), and where to send the user afterward (the redirect URI). Entra ID shows the user a consent dialog listing what the application is asking for. The user clicks Accept. Entra ID issues an authorization code to the redirect URI. The application exchanges that code for an access token. The application uses the access token to call Microsoft Graph. Microsoft Graph returns the data.

The access token is a signed JWT (JSON Web Token) that Microsoft Graph accepts as proof that a user consented to the requested permissions. It has an expiry typically one hour and a refresh token that lets the application get new access tokens without re-prompting the user.

Delegated Permissions vs Application Permissions

This distinction is the single most important concept in this entire article. The type of permission an application requests determines what it can access and whether admin approval is required.

Delegated permissions mean the app acts on behalf of a signed-in user. The application can only access what that specific user can access. If Alice consents to Mail.Read as a delegated permission, the app can read Alice’s email and only Alice’s email. The access token contains Alice’s identity and her access boundaries.

Application permissions mean the app acts as itself, not on behalf of any user. No user needs to be signed in. The application gets tenant-wide access to the resource. If Mail.Read is granted as an application permission, the app can read every mailbox in the entire tenant without any user being involved. Application permissions always require administrator consent there is no way for a standard user to grant them.

The attack chain this article documents starts with delegated permissions (which any user can self-consent) and escalates to application permissions (which require admin approval but can be requested through a workflow that IT staff often approve without fully understanding the implications).

Standard user creates app registration
              ↓
App requests Mail.Read (delegated permission)
              ↓
User consents  zero admin approval needed
              ↓
App can read that user's email programmatically
              ↓
User submits admin consent request for Mail.Read (application permission)
              ↓
Cloud Application Administrator approves it
              ↓
App can now read every mailbox in the entire tenant
              ↓
No new alert. No new audit flag. Nobody noticed.

Microsoft Graph Every Piece of Your Organization’s Data

Microsoft Graph at https://graph.microsoft.com is the unified API surface for all Microsoft 365 data. One endpoint, one authentication model, one permission system covering mail, calendar, files, Teams messages, user profiles, groups, security events, and more.

Every sensitive piece of data your organization holds in Microsoft 365 is accessible through Graph if the right permissions are in place. A brief overview of what each permission unlocks:

Mail.Read gives read access to the mailbox of whoever consented (delegated) or all mailboxes in the tenant (application). Mail.ReadWrite adds the ability to create, modify, and delete email. Files.Read.All gives read access to SharePoint and OneDrive. Calendars.Read gives access to calendar events. User.Read.All gives read access to every user profile in the directory. Chat.Read gives read access to Teams messages. Sites.Read.All gives access to every SharePoint site.

When granted as application permissions with the .All variants, a single approved app registration becomes a permanent API-level backdoor to everything. And once granted, those permissions persist until an administrator explicitly removes them or until someone notices.

Microsoft’s consent model has three tiers and understanding which tier applies to which permission determines the entire risk profile.

User consent means the user can grant this themselves with no admin involvement. This applies to low-sensitivity delegated permissions that only affect the consenting user’s own data. Mail.Read delegated, Calendars.Read, Files.ReadWrite all of these are self-consentable by any user by default. This is where the initial exposure in this article lives.

Admin consent required means only a Global Admin, Application Administrator, or Cloud Application Administrator can grant this. It applies to application permissions and higher-sensitivity delegated permissions like User.Read.All. These require explicit administrator approval before the app can use them.

The admin consent workflow is what happens when a user tries to consent to a permission that requires admin approval. Rather than just blocking the user, the tenant can be configured to let them submit a justification request. A reviewer gets an email and can approve or deny. This workflow is not enabled by default and if it is not configured, users who need admin-approved permissions just see an error with no path forward.

The “Users Can Register Applications” Setting

This single toggle controls the entire attack surface documented in this article. You find it in:

Entra admin center → Identity → Users → User settings → App registrations → “Users can register applications”

When set to Yes, which is the default, any user in the tenant including guest accounts can create app registrations, add API permissions to them, create client secrets, consent to delegated permissions on their own behalf, and invite other users to become co-owners of the app.

When set to No, only users with the Application Administrator or Global Administrator role can create app registrations.

Most organizations have never changed this setting. It has been Yes since the day the tenant was provisioned.

The Enumeration Gap Why Nobody Has Looked

Security teams audit service principals and enterprise applications with some regularity. But the app registrations themselves particularly ones created by standard users are almost never audited. The Microsoft Entra portal shows you a list of all app registrations but gives you no easy way to answer the questions that actually matter from a security perspective: who created this, are they still at the company, what does this app actually have permission to do, and when was the last time anyone used it?

The Microsoft Graph API can answer all of these questions, but there is no built-in report that surfaces this information in the portal. In a tenant of 5,000 users you may find dozens or hundreds of user-created app registrations, many of which have sensitive permissions, some of which have owners who left the organization years ago.

Orphaned App Registrations

An orphaned app registration is one where the original owner has left the organization or been disabled. The app still exists. Its service principal still exists. Any client secrets it has are still valid. And if it had sensitive permissions approved, those permissions are still active.

This is the path where a legitimate developer tool becomes a persistent access backdoor. Developer creates a data sync tool, gets admin consent for Mail.ReadWrite as an application permission, leaves the company, their account is disabled, and the app keeps working indefinitely. Anyone who somehow obtains the client secret through a code repository, a leaked config file, an old handover document can authenticate as the application and access data across the entire tenant.

What Attackers Do With This

Two distinct threat models are documented by security researchers and threat intelligence teams.

The insider threat model involves a current employee creating an app, granting themselves delegated permissions that go beyond what their normal email client logs would show, and using it to exfiltrate email over time. The activity appears in audit logs as application traffic many DLP solutions are tuned to watch for user interactive activity and miss API-based access entirely.

The OAuth phishing model involves an external attacker creating a malicious application and sending phishing emails containing a link that initiates an OAuth consent flow. If the target clicks the link and approves the consent dialog, the attacker’s application receives a delegated token for that user’s data. The attacker never needed the password. No MFA prompt was triggered. The access persists until the app’s service principal is removed which never happens if nobody knows to look for it. CISA issued advisories about this technique in 2024 and 2025 because it has been used in real attacks against US government and critical infrastructure organizations.


Before You Start

All labs in this article run entirely within your existing Microsoft 365 E5 tenant. No additional Azure spend is required. The vulnerability exists in the free default configuration you do not need any special licensing to reproduce it. You do need a test standard user account with no admin roles, a Cloud Application Administrator account for Lab 3, and your Global Admin account for the governance labs.

Everything demonstrated here is documented behavior that Microsoft has published guidance for. The purpose is to understand and close a real exposure in your own tenant.


Lab 1 Enumerate Every App Registration in the Tenant

Most organizations have never run this query. The output will surprise you.

Connecting with the Right Permissions

Install-Module Microsoft.Graph -Scope CurrentUser -Force -AllowClobber
Connect-MgGraph `
    -Scopes "Application.Read.All","Directory.Read.All","AuditLog.Read.All" `
    -UseDeviceAuthentication `
    -NoWelcome

# Verify you are connected to the right tenant
Get-MgContext | Select-Object Account, TenantId

Pasted image 20260430121738.png
Screenshot of the PowerShell terminal showing successful connection to Microsoft Graph with your tenant ID visible. This confirms you are running the audit against the correct tenant.

The Full Enumeration Script

# Pull every app registration in the tenant
$allApps = Get-MgApplication -All -Property `
    "id,appId,displayName,createdDateTime,signInAudience,requiredResourceAccess"

Write-Host "Total app registrations found: $($allApps.Count)"

# Build the risk report
$appReport = @()

foreach ($app in $allApps) {
    $owners = Get-MgApplicationOwner -ApplicationId $app.Id -ErrorAction SilentlyContinue
    
    $ownerDetails = @()
    foreach ($owner in $owners) {
        try {
            $user = Get-MgUser -UserId $owner.Id `
                -Property "displayName,userPrincipalName,accountEnabled" `
                -ErrorAction SilentlyContinue
            if ($user) {
                $roles = Get-MgUserMemberOf -UserId $owner.Id -ErrorAction SilentlyContinue |
                    Where-Object {
                        $_.AdditionalProperties["@odata.type"] -eq "#microsoft.graph.directoryRole"
                    }
                $ownerDetails += [PSCustomObject]@{
                    UPN      = $user.UserPrincipalName
                    IsAdmin  = $roles.Count -gt 0
                    Enabled  = $user.AccountEnabled
                }
            }
        } catch {}
    }
    
    # Count configured permissions
    $permCount = 0
    foreach ($resource in $app.RequiredResourceAccess) {
        $permCount += $resource.ResourceAccess.Count
    }
    
    $appReport += [PSCustomObject]@{
        AppName           = $app.DisplayName
        AppId             = $app.AppId
        ObjectId          = $app.Id
        Created           = $app.CreatedDateTime
        OwnerCount        = $ownerDetails.Count
        Owners            = ($ownerDetails | Select-Object -ExpandProperty UPN) -join "; "
        HasAdminOwner     = ($ownerDetails | Where-Object { $_.IsAdmin } | Measure-Object).Count -gt 0
        HasDisabledOwner  = ($ownerDetails | Where-Object { -not $_.Enabled } | Measure-Object).Count -gt 0
        IsOrphaned        = $ownerDetails.Count -eq 0
        PermissionCount   = $permCount
    }
}

# Print the summary
Write-Host "`n========================================"
Write-Host "APP REGISTRATION RISK SUMMARY"
Write-Host "========================================"
Write-Host "Total registrations:         $($appReport.Count)"
Write-Host "Admin-owned:                 $(($appReport | Where-Object { $_.HasAdminOwner }).Count)"
Write-Host "User-owned (non-admin):      $(($appReport | Where-Object { -not $_.HasAdminOwner -and -not $_.IsOrphaned }).Count)"
Write-Host "Orphaned (no owner):         $(($appReport | Where-Object { $_.IsOrphaned }).Count)"
Write-Host "Has disabled owner:          $(($appReport | Where-Object { $_.HasDisabledOwner }).Count)"

Pasted image 20260430121955.png
Screenshot of the PowerShell output showing the risk summary table total app registrations, how many are admin-owned versus user-owned, how many are orphaned. This is data that does not exist anywhere in the Azure portal dashboards. The numbers in your tenant are the headline finding for this section of the article.

Mapping Sensitive Permissions

Now cross-reference which apps have the permissions that actually matter:

# These are the GUIDs for high-sensitivity Microsoft Graph permissions
$sensitiveMap = @{
    "570282fd-fa5c-430d-a7fd-fc8dc98a9dca" = "Mail.Read (delegated)"
    "024d486e-b451-40bb-833d-3e66d98c5c73" = "Mail.ReadWrite (delegated)"
    "e2a3a72e-5f79-4c64-b1b1-878b674786c9" = "Mail.ReadWrite.All (application)"
    "810c84a8-4a9e-49e6-bf7d-12d183f40d01" = "Mail.Read (application)"
    "df021288-bdef-4463-88db-98f22de89214" = "User.Read.All (application)"
    "01d4889c-1287-42c6-ac1f-5d1e02578ef6" = "Files.Read.All (application)"
    "75359482-378d-4052-8f01-80520e7db3cd" = "Files.ReadWrite.All (application)"
    "06da0dbc-49e2-44d2-8312-e9d56496d205" = "Calendars.Read (delegated)"
    "7427e0e9-2fba-42fe-b0c0-848c9e6a8182" = "offline_access"
}

Write-Host "`n========================================"
Write-Host "APPS WITH SENSITIVE PERMISSIONS"
Write-Host "========================================"

foreach ($app in $allApps) {
    $found = @()
    foreach ($resource in $app.RequiredResourceAccess) {
        foreach ($scope in $resource.ResourceAccess) {
            if ($sensitiveMap.ContainsKey($scope.Id)) {
                $found += $sensitiveMap[$scope.Id]
            }
        }
    }
    if ($found.Count -gt 0) {
        $owners = Get-MgApplicationOwner -ApplicationId $app.Id -ErrorAction SilentlyContinue
        Write-Host ""
        Write-Host "App:         $($app.DisplayName)"
        Write-Host "Created:     $($app.CreatedDateTime)"
        Write-Host "Owners:      $($owners.Count)"
        Write-Host "Permissions: $($found -join ' | ')"
    }
}

Pasted image 20260430122100.png
Screenshot of the sensitive permissions output each app listing its name, creation date, owner count, and which high-sensitivity permissions it has configured. Right now none of the apps are registered but they'll be available here after we do lab2

This output is the core finding of Lab 1. In most enterprise tenants this query has never been run. The results are almost always surprising.


Lab 2 Standard User to Inbox Data in Under 10 Minutes

This is the demonstration that makes this article real. We are going to show, step by step, what the portal experience looks like for a standard user and exactly how quickly someone can go from nothing to reading their own mailbox via an API they registered themselves.

Registering the App as a Standard User

Sign out of your admin account completely. Sign into portal.azure.com as your test standard user the account with no admin roles whatsoever.

Navigate directly to: `https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade`

Click New registration.

Pasted image 20260430122643.png
Pasted image 20260430122803.png
Screenshot of the New Registration page in the Azure portal as a standard user. The form shows Name, Supported account types, and Redirect URI fields. Notice there is no approval gate, no warning that admin involvement is needed, and no indication this is anything unusual. This is what any of your 5,000 users sees when they follow a tutorial on the internet.

Fill in the fields:

  • Name: HR-Sync-Tool (deliberately chosen to sound like a legitimate internal tool)
  • Supported account types: Accounts in this organizational directory only
  • Redirect URI: Web http://localhost

Click Register.

Pasted image 20260430123011.png
Screenshot of the completed app registration Overview page showing the Application (Client) ID and Directory (Tenant) ID. Note these down you will need them shortly. The app now exists as a real identity in your corporate Entra ID directory and the person who created it has no admin roles.

Adding API Permissions

In the left menu of your new app registration click API permissions, then Add a permission, then Microsoft Graph, then Delegated permissions.

Search for Mail.Read and select it. Then search for User.Read and select it. Click Add permissions. ![[Pasted image 20260430123223.png||Screenshot of the API permissions page showing Mail.Read and User.Read listed as delegated permissions. Notice the Status column it says “Not granted for [tenant]” but there is no lock icon, no admin required warning visible to the standard user at this stage. The consent happens when they actually authenticate via the OAuth flow, not here.]]

Creating a Client Secret

Click Certificates and secrets in the left menu, then New client secret.

Set a description and leave the expiry at 24 months. Click Add.

Pasted image 20260430123316.png
Pasted image 20260430123349.png
Screenshot of the client secret creation page showing the newly generated secret value. This value is only shown once the screenshot captures it for the demo. In a real scenario this is the key that unlocks the application's ability to authenticate.

Copy three values from your app registration:

  • Client ID (from the Overview page)
  • Tenant ID (from the Overview page)
  • Client Secret Value (from the page you are currently on)

Running the OAuth Flow

Install the required Python library on your machine:

pip install msal requests

Pasted image 20260430123520.png

Create a file called mail_demo.py with the following content. Replace the placeholder values with your actual IDs:

import msal
import requests
import json

# Replace these with your actual values from the app registration
CLIENT_ID = "paste-your-client-id-here"
TENANT_ID = "paste-your-tenant-id-here"
CLIENT_SECRET = "paste-your-client-secret-here"

AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}"
SCOPES = ["Mail.Read", "User.Read", "offline_access"]

# Using the public client flow  this triggers the consent dialog
app = msal.PublicClientApplication(CLIENT_ID, authority=AUTHORITY)

# Device code flow is easiest to demonstrate
# It gives you a URL and a short code to enter in any browser
flow = app.initiate_device_flow(scopes=SCOPES)
print(f"\nOpen this URL in your browser: {flow['verification_uri']}")
print(f"Enter this code when prompted:  {flow['user_code']}")
print("\nWaiting for you to authenticate and consent...")

# This blocks until the user completes the browser auth
result = app.acquire_token_by_device_flow(flow)

if "access_token" in result:
    print("\n✅ Token acquired successfully")
    print(f"Token expires in: {result['expires_in']} seconds")
    
    headers = {"Authorization": f"Bearer {result['access_token']}"}
    
    # First confirm who we authenticated as
    me_response = requests.get(
        "https://graph.microsoft.com/v1.0/me",
        headers=headers
    )
    user_info = me_response.json()
    print(f"\nAuthenticated as: {user_info.get('userPrincipalName')}")
    print(f"Display name:     {user_info.get('displayName')}")
    
    # Now read the inbox
    messages_response = requests.get(
        "https://graph.microsoft.com/v1.0/me/messages"
        "?$top=5&$select=subject,from,receivedDateTime,bodyPreview",
        headers=headers
    )
    
    print(f"\n✅ Inbox API call  HTTP {messages_response.status_code}")
    print("\nMost recent messages:")
    
    for msg in messages_response.json().get("value", []):
        sender = msg["from"]["emailAddress"]["address"]
        subject = msg["subject"]
        received = msg["receivedDateTime"]
        preview = msg["bodyPreview"][:100] if msg.get("bodyPreview") else ""
        print(f"\n  From:    {sender}")
        print(f"  Subject: {subject}")
        print(f"  Date:    {received}")
        print(f"  Preview: {preview}...")
        
else:
    print(f"\nError: {result.get('error_description', 'Unknown error')}")

Run it:

python mail_demo.py

Open the URL that appears in your terminal. Sign in as your test standard user. You will see a consent dialog. ![Pasted image 20260430130639.png](/img/posts/Pasted%20image%2020260430130639.png “Screenshot of the Microsoft consent dialog showing “HR-Sync-Tool wants to: Read your mail messages, Sign you in and read your profile.” This is the exact experience your users see. Notice it is an official Microsoft-branded dialog, it lists the permissions clearly, and there is no indication that this is unusual or that it was created by a non-admin. Many users will click Accept without hesitation.”)

Click Accept. Return to your terminal.

What Did the Logs Capture?

Pull the audit log immediately:

Connect-MgGraph -Scopes "AuditLog.Read.All"

# Look for the consent event from the last hour
$events = Get-MgAuditLogDirectoryAudit `
    -Filter "activityDisplayName eq 'Consent to application' and activityDateTime ge $((Get-Date).AddHours(-1).ToString('yyyy-MM-ddTHH:mm:ssZ'))" `
    -Top 10

foreach ($event in $events) {
    Write-Host "================================"
    Write-Host "Operation: $($event.ActivityDisplayName)"
    Write-Host "Time:      $($event.ActivityDateTime)"
    Write-Host "User:      $($event.InitiatedBy.User.UserPrincipalName)"
    Write-Host "Result:    $($event.Result)"
    Write-Host "App:       $($event.TargetResources[0].DisplayName)"
}

The log entry exists. It records that consent happened. But in a default Sentinel deployment with no custom rules, this event generates no alert, no incident, and no notification to the security team. A user-created app now has delegated access to the user’s mailbox and the SOC will never know unless they specifically go looking.


One step further. The same user-created app can request application-level permissions, submit an admin consent request, and if a Cloud Application Administrator approves it which is a role commonly held by IT staff and helpdesk managers the app gains access to every mailbox in the tenant.

Requesting an Application Permission

Still logged in as the test standard user, go back to the app registration’s API permissions page.

Click Add a permissionMicrosoft GraphApplication permissions.

Search for Mail.Read and select it. Click Add permissions.

You will see the permission listed with an “Admin consent required” indicator.

Click Grant admin consent for [tenant]. You will see an authorization error expected, you are a standard user.

Instead of the direct grant button, click the Request admin consent option. Fill in the justification field: “Required for the HR data synchronization process that was approved by management.”

Submit the request.

Approving as Cloud Application Administrator

Switch to your Cloud Application Administrator account. In Entra admin center navigate to Identity → Enterprise Applications → Admin consent requests.

Click Review permissions and consent. The portal shows a detailed breakdown of what is being requested. Click Accept.

Accessing Another User’s Mailbox

Now update the Python script to use the client credentials flow this is the application permission flow where no user needs to be signed in:

import msal
import requests

CLIENT_ID = "paste-your-client-id-here"
TENANT_ID = "paste-your-tenant-id-here"
CLIENT_SECRET = "paste-your-client-secret-here"

AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}"

# Confidential client for application permissions
app = msal.ConfidentialClientApplication(
    CLIENT_ID,
    authority=AUTHORITY,
    client_credential=CLIENT_SECRET
)

# Client credentials flow  no user, no consent prompt, just the app authenticating as itself
result = app.acquire_token_for_client(
    scopes=["https://graph.microsoft.com/.default"]
)

if "access_token" in result:
    print("✅ Application token acquired  no user sign-in required")
    
    headers = {"Authorization": f"Bearer {result['access_token']}"}
    
    # Access a completely different user's mailbox
    # This user never consented to anything  the app permission is tenant-wide
    target_user = "another.user@yourtenant.onmicrosoft.com"
    
    response = requests.get(
        f"https://graph.microsoft.com/v1.0/users/{target_user}/messages"
        "?$top=5&$select=subject,from,receivedDateTime",
        headers=headers
    )
    
    print(f"\nAccessing {target_user}'s mailbox: HTTP {response.status_code}")
    
    if response.status_code == 200:
        print("✅ Cross-user mailbox access confirmed")
        for msg in response.json().get("value", []):
            print(f"  Subject: {msg['subject']}")
            print(f"  From:    {msg['from']['emailAddress']['address']}")
    
    # Show that this token works for every user in the tenant
    all_users = requests.get(
        "https://graph.microsoft.com/v1.0/users?$select=userPrincipalName&$top=10",
        headers=headers
    )
    print(f"\nAll users accessible: HTTP {all_users.status_code}")
    print("This token provides read access to every mailbox in the tenant")

Lab 4 The Governance Framework

Three controls close this exposure. You should implement all three.

Control 1 Disable User App Registration

In Entra admin center navigate to Identity → Users → User settings. Under App registrations, set “Users can register applications” to No. Click Save.

# Or via PowerShell  same result
Connect-MgGraph -Scopes "Policy.ReadWrite.Authorization"

Update-MgPolicyAuthorizationPolicy -BodyParameter @{
    defaultUserRolePermissions = @{
        allowedToCreateApps = $false
    }
}

# Verify
$policy = Get-MgPolicyAuthorizationPolicy
Write-Host "Users can register apps: $($policy.DefaultUserRolePermissions.AllowedToCreateApps)"
# Should output: False

Now test it sign in as the standard user and try to register a new app. The New Registration button is greyed out or shows a permissions error.

The admin consent workflow should route requests to a named security reviewer, not a shared inbox that nobody monitors:

# First find your security review group ID
$reviewGroup = Get-MgGroup -Filter "displayName eq 'Security Reviewers'"
$reviewGroupId = $reviewGroup.Id

# Configure the workflow
$policyParams = @{
    isEnabled             = $true
    version               = 1
    notifyReviewers       = $true
    remindersEnabled      = $true
    requestDurationInDays = 14
    reviewers = @(
        @{
            query     = "/groups/$reviewGroupId/transitiveMembers"
            queryType = "MicrosoftGraph"
        }
    )
}

Update-MgPolicyAdminConsentRequestPolicy -BodyParameter $policyParams

# Verify
$consentPolicy = Get-MgPolicyAdminConsentRequestPolicy
Write-Host "Consent workflow enabled: $($consentPolicy.IsEnabled)"

Control 3 Sentinel Detection Rules

These three KQL rules cover the event types that matter. Create each one as a scheduled analytics rule in Sentinel with a 5-minute run frequency and an alert threshold of greater than zero.

// Rule 1: App registration created by a non-admin user
// Priority: Medium
let AdminRoles = dynamic([
    "Global Administrator",
    "Application Administrator",
    "Cloud Application Administrator"
]);
AuditLogs
| where TimeGenerated > ago(1h)
| where OperationName == "Add application"
| where Result == "success"
| extend CreatedBy = tostring(InitiatedBy.user.userPrincipalName)
| extend AppName = tostring(TargetResources[0].displayName)
| join kind=leftouter (
    AuditLogs
    | where OperationName == "Add member to role"
    | mv-expand TargetResources
    | extend RoleMember = tostring(TargetResources.userPrincipalName)
    | extend RoleName = tostring(TargetResources.displayName)
    | where RoleName in (AdminRoles)
    | distinct RoleMember
) on $left.CreatedBy == $right.RoleMember
| where isempty(RoleMember)
| project TimeGenerated, AppName, CreatedBy, Result
| sort by TimeGenerated desc
// Rule 2: User self-consented to an application
// Priority: Low  expected after hardening, unexpected before it
AuditLogs
| where TimeGenerated > ago(1h)
| where OperationName == "Consent to application"
| where Result == "success"
| extend ConsentingUser = tostring(InitiatedBy.user.userPrincipalName)
| extend AppName = tostring(TargetResources[0].displayName)
| project TimeGenerated, ConsentingUser, AppName
| sort by TimeGenerated desc
// Rule 3: Admin consent approved for application-level permission
// Priority: High  application permissions are tenant-wide
AuditLogs
| where TimeGenerated > ago(24h)
| where OperationName == "Consent to application"
| where Result == "success"
| extend ApprovedBy = tostring(InitiatedBy.user.userPrincipalName)
| extend AppName = tostring(TargetResources[0].displayName)
| extend PermDetails = tostring(TargetResources[0].modifiedProperties)
| where PermDetails has "AllPrincipals"
| project TimeGenerated, ApprovedBy, AppName, PermDetails
| sort by TimeGenerated desc

Lab 5 Automated Remediation

Notifying Existing App Owners

Connect-MgGraph -Scopes "Mail.Send","Application.ReadWrite.All","Directory.Read.All"

# Use the $appReport from Lab 1
$remediationCandidates = $appReport | Where-Object {
    -not $_.HasAdminOwner -and $_.PermissionCount -gt 0
}

Write-Host "Apps requiring owner review: $($remediationCandidates.Count)"

foreach ($candidate in $remediationCandidates) {
    if (-not $candidate.IsOrphaned -and $candidate.Owners) {
        $ownerEmail = $candidate.Owners.Split(";")[0].Trim()
        
        $emailParams = @{
            message = @{
                subject = "Action Required  Application Registration Review: $($candidate.AppName)"
                body = @{
                    contentType = "HTML"
                    content = @"
<p>Hi,</p>
<p>Our security team has identified you as the owner of the application 
<strong>$($candidate.AppName)</strong>, registered on $($candidate.Created).</p>
<p>This application has <strong>$($candidate.PermissionCount) API permissions</strong> 
configured. As part of our app registration governance review, we are asking all 
owners to confirm whether their application is still required.</p>
<p>Please reply within <strong>14 days</strong> to confirm the app is still needed, 
or to let us know it can be disabled.</p>
<p>Applications with no response after 14 days will be automatically disabled.</p>
<p>Security Team</p>
"@
                }
                toRecipients = @(@{
                    emailAddress = @{ address = $ownerEmail }
                })
            }
        }
        
        Invoke-MgGraphRequest `
            -Method POST `
            -Uri "https://graph.microsoft.com/v1.0/me/sendMail" `
            -Body $emailParams `
            -ContentType "application/json"
        
        Write-Host "Notification sent to $ownerEmail for app: $($candidate.AppName)"
    }
}

Disabling Orphaned Apps Immediately

Orphaned apps have no owner to notify. Disable them now:

$orphanedApps = $appReport | Where-Object { $_.IsOrphaned }
Write-Host "Orphaned apps to disable: $($orphanedApps.Count)"

foreach ($orphan in $orphanedApps) {
    # Find the service principal and disable it
    $sp = Get-MgServicePrincipal -Filter "appId eq '$($orphan.AppId)'" -ErrorAction SilentlyContinue
    
    if ($sp) {
        Update-MgServicePrincipal `
            -ServicePrincipalId $sp.Id `
            -AccountEnabled $false
        
        Write-Host "Disabled: $($orphan.AppName) (created $($orphan.Created))"
    }
}

Verifying Token Invalidation

After disabling an app, any previously issued tokens should fail:

import requests

# Paste a previously obtained access token here
old_token = "eyJ0eXAiOiJKV1Qi..."

headers = {"Authorization": f"Bearer {old_token}"}

response = requests.get(
    "https://graph.microsoft.com/v1.0/me/messages",
    headers=headers
)

print(f"HTTP Status: {response.status_code}")
print(f"Response: {response.json().get('error', {}).get('message', 'Success')}")

The Full Governance Script

Deploy this against your tenant today. It does everything in order: checks the current setting, disables user registration, audits existing apps, and outputs a prioritized remediation list.

# ============================================
# ENTRA ID APP REGISTRATION GOVERNANCE AUDIT
# Run as Global Administrator
# ============================================

Connect-MgGraph `
    -Scopes "Application.Read.All","Policy.ReadWrite.Authorization",
            "AuditLog.Read.All","Directory.Read.All" `
    -UseDeviceAuthentication `
    -NoWelcome

Write-Host "=== CURRENT CONFIGURATION ===" -ForegroundColor Cyan
$authPolicy = Get-MgPolicyAuthorizationPolicy
Write-Host "Users can register apps: $($authPolicy.DefaultUserRolePermissions.AllowedToCreateApps)"

if ($authPolicy.DefaultUserRolePermissions.AllowedToCreateApps) {
    Write-Host "WARNING: Standard users can currently register apps" -ForegroundColor Red
    $disable = Read-Host "Disable this now? (Y/N)"
    if ($disable -eq "Y") {
        Update-MgPolicyAuthorizationPolicy -BodyParameter @{
            defaultUserRolePermissions = @{ allowedToCreateApps = $false }
        }
        Write-Host "App registration by standard users: DISABLED" -ForegroundColor Green
    }
}

Write-Host "`n=== APP REGISTRATION AUDIT ===" -ForegroundColor Cyan
$apps = Get-MgApplication -All
$results = @{ Total = $apps.Count; Orphaned = 0; UserOwned = 0; AdminOwned = 0 }

foreach ($app in $apps) {
    $owners = Get-MgApplicationOwner -ApplicationId $app.Id -ErrorAction SilentlyContinue
    if ($owners.Count -eq 0) {
        $results.Orphaned++
        continue
    }
    $hasAdmin = $false
    foreach ($owner in $owners) {
        $roles = Get-MgUserMemberOf -UserId $owner.Id -ErrorAction SilentlyContinue |
            Where-Object { $_.AdditionalProperties["@odata.type"] -eq "#microsoft.graph.directoryRole" }
        if ($roles.Count -gt 0) { $hasAdmin = $true }
    }
    if ($hasAdmin) { $results.AdminOwned++ } else { $results.UserOwned++ }
}

Write-Host "Total app registrations: $($results.Total)"
Write-Host "Admin-owned:             $($results.AdminOwned)"
Write-Host "User-owned:              $($results.UserOwned)  ← review these"
Write-Host "Orphaned:                $($results.Orphaned)  ← disable these immediately"

What to Do Right Now

Three things, in order of priority.

First, run the Lab 1 enumeration script against your tenant. The output tells you your actual exposure. Do not skip this step and go straight to hardening you need to know what already exists before you change settings, otherwise you may not realize what you are inheriting.

Second, disable “Users can register applications.” This is one setting change that eliminates the entire self-service path for new exposure. It takes 30 seconds and has no impact on existing apps or admin-created apps.

Third, review the orphaned apps. Any app with sensitive permissions and no active owner should be disabled immediately. There is no notification needed there is nobody to notify.

Everything else the consent workflow configuration, the Sentinel rules, the automated remediation is important and worth implementing. But those first three steps reduce your exposure today.


Conclusion

The most dangerous app in your tenant was registered by a user who thought they were saving time. Maybe it was a developer prototyping a data integration. Maybe it was a power user who found a tutorial. Maybe it was someone who left the company three years ago and the app kept running. The app is there. The permissions are active. And until someone runs the enumeration query, nobody knows.

This is not a theoretical risk. OAuth application abuse has been used in documented incidents against enterprise tenants, government organizations, and critical infrastructure. CISA issued advisories about it. Microsoft published guidance about it. The technique works because it exploits features that are supposed to exist the attack surface is the default configuration, not a bug.

Run the audit. Close the settings gap. Monitor for new registrations. Find out what is already in your tenant before someone else does.


All research and demonstrations were conducted in isolated test environments for educational and defensive security purposes.

References

ResourceLink
Microsoft User and admin consent in Entra IDhttps://learn.microsoft.com/entra/identity/enterprise-apps/user-admin-consent-overview
Microsoft Configure how users consent to applicationshttps://learn.microsoft.com/entra/identity/enterprise-apps/configure-user-consent
Microsoft Admin consent workflowhttps://learn.microsoft.com/entra/identity/enterprise-apps/configure-admin-consent-workflow
Microsoft Graph Applications endpoint referencehttps://learn.microsoft.com/graph/api/resources/application
CISA Protecting Against OAuth Application Abusehttps://www.cisa.gov/news-events/cybersecurity-advisories/aa24-242a
Invictus IR Hunting Malicious OAuth Applications in M365https://www.invictus-ir.com/news/hunting-for-malicious-oauth-applications-in-microsoft-365
Microsoft Continuous Access Evaluationhttps://learn.microsoft.com/entra/identity/conditional-access/concept-continuous-access-evaluation
OWASP Top 10 Broken Access Controlhttps://owasp.org/Top10/A01_2021-Broken_Access_Control/