Entra ID app registrations vs enterprise apps: where consent lives
For developers wiring up Microsoft identity, the confusing part is usually not OAuth itself but Entra ID’s object model: app registrations, enterprise applications, service principals, and consent. This guide maps those objects to the actual request flow, shows where tenant-wide and user consent are stored, and gives you concrete commands and payloads you can use to inspect and troubleshoot it.
TL;DR — In Entra ID, the app registration is the application definition/template, while the enterprise application is the per-tenant instantiated service principal that actually participates in sign-in and holds assignments and most consent state. If you are debugging "why can’t users sign in" or "why did admin consent not apply," inspect the service principal and its permission grants in the target tenant, not just the app registration in the home tenant. Reading time: ~7 min
What it is and where it sits
If you build against Entra ID, you are dealing with two different directory objects that people casually treat as one thing:
- App registration: the application object. Think of it as the blueprint: client ID, redirect URIs, exposed scopes/app roles, secrets/certs, API permissions requested by the app definition.
- Enterprise application: the service principal. Think of it as the tenant-local instance of that app. This is what users, groups, conditional access, assignments, and many consent/grant records attach to.
This split exists because one app definition can be used in many tenants. The home tenant owns the application object. Every tenant that uses the app gets its own service principal.
What it replaced conceptually: older Azure AD docs often blurred “application” and “service principal.” Entra ID still uses both under the hood; the portal just labels service principals as Enterprise applications.
In a typical OAuth/OIDC flow:
Browser / SPA / native app
|
| 1. /authorize?client_id=...
v
Entra ID authorization endpoint
|
| looks up application object by client_id
| resolves tenant-local service principal
v
User signs in / admin policy checks
|
| consent + assignments + CA evaluated against service principal
v
Token issued
|
| aud / scp / roles reflect grants
v
Your app or API
The architecture context that matters for decisions:
- Application object lives once, in the app’s home tenant.
- Service principal lives in each consuming tenant.
- Consent for delegated permissions is usually represented as OAuth permission grants in the consuming tenant, tied to the client service principal and the resource service principal.
- Consent for application permissions / app roles is represented as app role assignments, again in the consuming tenant.
That last point is the source of most confusion: developers add API permissions on the app registration and assume consent “lives there.” It does not, operationally. The request is declared on the app registration; the usable grant exists against service principals in a tenant.
How it actually works
Walk one realistic example: you build a multi-tenant web app that signs users in and calls Microsoft Graph User.Read and Mail.Read delegated permissions.
Step 1: Create the app registration in the home tenant
You create an app registration and set redirect URIs. This creates an application object with an appId (client ID).
Using Microsoft Graph, the shape looks like this:
{
"id": "11111111-2222-3333-4444-555555555555",
"appId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"displayName": "contoso-web",
"signInAudience": "AzureADMultipleOrgs",
"web": {
"redirectUris": [
"https://app.contoso.com/auth/callback"
]
},
"requiredResourceAccess": [
{
"resourceAppId": "00000003-0000-0000-c000-000000000000",
"resourceAccess": [
{ "id": "e1fe6dd8-ba31-4d61-89e7-88639da4683d", "type": "Scope" },
{ "id": "570282fd-fa5c-430d-a7fd-fc8dc98a9dca", "type": "Scope" }
]
}
]
}
requiredResourceAccess says: “this app wants Graph scopes.” It does not mean any tenant has granted them yet.
Step 2: First user from another tenant hits sign-in
The user goes to:
https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee&response_type=code&redirect_uri=https%3A%2F%2Fapp.contoso.com%2Fauth%2Fcallback&scope=openid%20profile%20offline_access%20User.Read%20Mail.Read
Entra ID finds the application object by client_id. Because this is a multi-tenant app and the user is from Fabrikam, Entra ID creates or resolves a service principal in Fabrikam’s tenant for your app.
If user consent is allowed and the scopes are user-consentable, the user may see a consent prompt. If admin consent is required, they get an error instead.
Common error shape when admin consent is needed:
AADSTS65001: The user or administrator has not consented to use the application with ID 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' named 'contoso-web'. Send an interactive authorization request for this user and resource.
Step 3: Where the consent is stored
For delegated permissions, the usable grant is stored as an OAuth2 permission grant in Fabrikam’s tenant. Conceptually:
- client = Fabrikam service principal for
contoso-web - resource = Fabrikam service principal for Microsoft Graph
- scope =
User.Read Mail.Read - consent type =
Principal(one user) orAllPrincipals(admin consent for the tenant)
For application permissions, the grant would instead be an app role assignment from your app’s service principal to the resource service principal.
Step 4: Token issuance uses the tenant-local objects
On the next token request, Entra ID evaluates the service principal and grant records in Fabrikam’s tenant. If the grant exists, the access token includes the approved scopes.
If it does not, your code exchange may succeed for ID token but fail for access token acquisition, depending on the flow and library behavior.
Step 5: Inspect the actual state, not the portal labels
With Microsoft Graph CLI or raw HTTP, inspect the service principal and grants in the target tenant.
Get the service principal for your app in the signed-in tenant:
graph sp list --filter "appId eq 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'"
Expected output shape:
{
"value": [
{
"id": "99999999-8888-7777-6666-555555555555",
"appId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"displayName": "contoso-web",
"appOwnerOrganizationId": "home-tenant-guid"
}
]
}
Then inspect delegated grants:
curl -s -H "Authorization: Bearer $TOKEN" \
"https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=clientId%20eq%20'99999999-8888-7777-6666-555555555555'"
Output shape:
{
"value": [
{
"id": "grant-id",
"clientId": "99999999-8888-7777-6666-555555555555",
"resourceId": "graph-sp-object-id-in-tenant",
"consentType": "AllPrincipals",
"scope": "Mail.Read User.Read"
}
]
}
If that array is empty, your app registration may look perfect and the app still cannot call Graph in that tenant.
When to use it (and when not to)
You do not choose between app registration and enterprise application; you need to understand which object to modify or inspect for the job at hand.
| Scenario | Recommendation |
|---|---|
| You need a client ID, redirect URI, secret/cert, exposed API scopes, app roles | Work on the app registration |
| You need to assign users/groups, require assignment, review sign-ins, or inspect tenant-local policy impact | Work on the enterprise application |
| You are debugging delegated consent in a customer tenant | Inspect oauth2PermissionGrants on the service principal |
| You are debugging app-only/API permissions | Inspect app role assignments on the service principal |
| Single-tenant internal app, one org only | You still have both objects, but the distinction matters less operationally |
| Multi-tenant SaaS | Learn this model properly; most production auth bugs happen at the service principal/grant layer |
| You just want “login with Microsoft” for a hobby app and no downstream API access | You probably don’t need deep consent modeling yet; basic app registration setup may be enough |
| You are trying to fix redirect URI mismatch or invalid client secret | Start with the app registration, not enterprise applications |
You probably don’t need to think hard about enterprise applications if all of these are true:
- your app is single-tenant,
- you do not call downstream APIs beyond basic sign-in,
- you are not using app roles, assignments, or tenant-wide admin consent,
- and nobody outside your home tenant uses the app.
The moment you go multi-tenant or ask for Graph/API permissions, this becomes operationally important.
Trade-offs
Every benefit of this split has a cost.
-
Benefit: one app definition, many tenants can use it
Cost: you now have per-tenant state to debug. A customer can have a broken service principal or stale grants even when your home-tenant app registration is correct. -
Benefit: tenant admins control consent and assignments locally
Cost: onboarding friction. Your SaaS may need an admin-consent step, and support tickets become “works in one tenant, fails in another.” -
Benefit: app registration cleanly defines requested permissions
Cost: false confidence. Declared permissions are not effective permissions until grants exist. -
Benefit: enterprise application can be governed by tenant policy
Cost: conditional access, assignment requirements, and consent policies can block sign-in in ways your app cannot override. -
Benefit: supports least privilege and revocation per tenant
Cost: revocation and re-consent workflows need to be part of your operational playbook.
Lock-in note: this object split is Entra-specific terminology, but the underlying pattern is common: a global app definition plus tenant-local principal/grants. If you abstract too aggressively in your code or docs, your team will lose the ability to diagnose real Entra issues.
In practice
Example 1: Find the service principal and delegated grants in the current tenant
APP_ID="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
TOKEN="$(az account get-access-token --resource-type ms-graph --query accessToken -o tsv)"
SP_JSON=$(curl -s -H "Authorization: Bearer $TOKEN" \
"https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId%20eq%20'$APP_ID'")
echo "$SP_JSON" | jq '.value[0] | {id, appId, displayName, appOwnerOrganizationId}'
SP_ID=$(echo "$SP_JSON" | jq -r '.value[0].id')
curl -s -H "Authorization: Bearer $TOKEN" \
"https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=clientId%20eq%20'$SP_ID'" | jq '.value[] | {consentType, scope, resourceId}'
This fetches the tenant-local service principal for your app and lists delegated permission grants. Gotcha: az account get-access-token --resource-type ms-graph requires a signed-in identity with Graph read rights; in locked-down tenants you may get Authorization_RequestDenied even though the app itself exists.
Typical failure output when your operator account lacks rights:
{
"error": {
"code": "Authorization_RequestDenied",
"message": "Insufficient privileges to complete the operation.",
"innerError": {
"date": "2026-10-01T10:15:00Z",
"request-id": "...",
"client-request-id": "..."
}
}
}
Example 2: Trigger admin consent explicitly and verify the redirect
https://login.microsoftonline.com/organizations/v2.0/adminconsent?client_id=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee&redirect_uri=https%3A%2F%2Fapp.contoso.com%2Fauth%2Fadmin-consent
Before sending a customer admin there, verify the redirect URI is registered and responds correctly:
curl -I https://app.contoso.com/auth/admin-consent
Healthy output shape:
HTTP/2 200
content-type: text/html; charset=utf-8
cache-control: no-store
Misconfigured app output shape that causes post-consent failure:
HTTP/2 404
content-type: text/plain; charset=utf-8
This URL starts the tenant-wide admin consent flow. Gotcha: the redirect_uri must exactly match a registered redirect URI on the app registration, including path and scheme; otherwise Entra returns a redirect mismatch error before consent completes.
Typical error:
AADSTS50011: The redirect URI 'https://app.contoso.com/auth/admin-consent/' specified in the request does not match the redirect URIs configured for the application 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'.
Example 3: Inspect app-only permission grants via app role assignments
RESOURCE_SP_ID="graph-service-principal-object-id"
CLIENT_SP_ID="99999999-8888-7777-6666-555555555555"
TOKEN="$(az account get-access-token --resource-type ms-graph --query accessToken -o tsv)"
curl -s -H "Authorization: Bearer $TOKEN" \
"https://graph.microsoft.com/v1.0/servicePrincipals/$CLIENT_SP_ID/appRoleAssignments" | jq '.value[] | {resourceId, appRoleId, principalDisplayName}'
This lists application permission grants assigned to the client service principal. Gotcha: developers often look for app-only consent in oauth2PermissionGrants; that endpoint is for delegated grants, not app roles.
⚠️ Deleting grants or app role assignments can immediately break production sign-in or API calls for an entire tenant. If you are testing revocation, do it in a non-production tenant first and record the object IDs you remove so you can restore them deliberately.
Further reading
- Microsoft Graph REST API v1.0 — Application resource type
- Microsoft Graph REST API v1.0 — Service principal resource type
- Microsoft Graph REST API v1.0 — oAuth2PermissionGrant resource type
- Microsoft identity platform — Admin consent
- OAuth 2.0 Authorization Framework (RFC 6749)
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI