Dynamics 365 Authentication: Azure AD/Entra ID OAuth 2.0, S2S, and App Registration
How does Dynamics 365 authentication work - Azure AD OAuth 2.0, service-to-service, app registration?
TL;DR
- Bottom line: Dynamics 365 (Dataverse) uses Microsoft Entra ID (formerly Azure AD) as its identity provider with OAuth 2.0. For integrations, use the client credentials flow with an application user; for user-context operations, use the authorization code flow with delegated permissions.
- Key limit: Access tokens expire in ~60-75 minutes; client secrets max out at 2 years, certificates at 3 years. Plan automated rotation. [src1]
- Watch out for: ADAL is fully deprecated since June 2022 - you must use MSAL (Microsoft Authentication Library). Any code still using ADAL will fail. [src1]
- Best for: Any application integrating with Dynamics 365 Customer Engagement (Sales, Service, Marketing), Dataverse, or Power Platform APIs.
- Authentication: OAuth 2.0 via Microsoft Entra ID. Client credentials (S2S) for unattended integrations; authorization code for user-context; managed identity for Azure-hosted plug-ins. [src1, src2]
System Profile
This card covers authentication for Microsoft Dynamics 365 Customer Engagement apps (Sales, Service, Marketing, Customer Insights) that use Microsoft Dataverse as their data platform. All Dataverse-based D365 apps share the same authentication infrastructure through Microsoft Entra ID. This card does NOT cover Dynamics 365 Business Central (which has its own S2S flow) or Dynamics 365 Finance & Operations (which uses a different token endpoint pattern). [src1, src2]
| Property | Value |
|---|---|
| Vendor | Microsoft |
| System | Dynamics 365 (Dataverse) Web API v9.2 |
| API Surface | OData v4 (REST) |
| Current API Version | v9.2 (2026) |
| Editions Covered | Sales, Service, Marketing, Customer Insights, Power Apps (all Dataverse-based) |
| Deployment | Cloud (Dynamics 365 Online) |
| API Docs | Microsoft Dataverse Web API |
| Status | GA |
API Surfaces & Capabilities
| API Surface | Protocol | Best For | Auth Flows Supported | Real-time? | Notes |
|---|---|---|---|---|---|
| Web API (OData v4) | HTTPS/JSON | REST integrations, any language | Client credentials, auth code, ROPC, managed identity | Yes | Primary API for all Dataverse operations |
| Organization Service (SDK) | .NET SDK | .NET applications, plug-ins | Client credentials, auth code, certificate | Yes | Requires Microsoft.PowerPlatform.Dataverse.Client NuGet |
| Dataverse Search API | HTTPS/JSON | Full-text search across entities | Same as Web API | Yes | Separate endpoint: {org}/api/search/v1.0/query |
Rate Limits & Quotas
Per-Request Limits
| Limit Type | Value | Applies To | Notes |
|---|---|---|---|
| Max records per query | 5,000 | Web API OData query | Use $top and @odata.nextLink for pagination |
| Max request body size | 128 MB | Web API | Larger payloads should use file/image column upload |
| Max batch subrequests | 1,000 | $batch endpoint | Each subrequest counts as a separate API request |
| Max concurrent connections | Per-tenant throttling | All API calls | Returns 429 when exceeded |
Rolling / Daily Limits
| Limit Type | Value | Window | Edition Differences |
|---|---|---|---|
| Power Platform API requests | Varies by license | 24h rolling | Enterprise: 20K/user/day; per-app: 1K/user/day; application user: 250K/tenant/day (pooled) |
| Concurrent batch requests | Throttled per-org | Per-org | Returns 429 Too Many Requests |
| ExecuteMultiple max | 1,000 requests per call | Per-request | Organization Service SDK only |
Authentication
All Dynamics 365 Dataverse authentication flows go through Microsoft Entra ID (formerly Azure AD) using OAuth 2.0. The token endpoint is https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token. [src1, src2]
| Flow | Use When | Token Lifetime | Refresh? | Notes |
|---|---|---|---|---|
| Client Credentials (S2S) | Server-to-server, no user context, unattended integrations | Access: ~60-75 min | No refresh token; request new before expiry | Recommended for integrations. Requires application user in Dataverse. |
| Authorization Code | User-context operations, interactive apps | Access: ~60-75 min; Refresh: up to 90 days | Yes | Requires redirect URI. User or admin must consent. |
| Authorization Code + PKCE | SPAs and mobile apps | Access: ~60-75 min; Refresh: 24h (SPA) | Yes (limited for SPA) | Required for public clients. |
| ROPC (username/password) | Legacy testing only | Access: ~60-75 min | No | Do NOT use in production. No MFA support. |
| Managed Identity | Azure-hosted Dataverse plug-ins | Managed by Azure | Automatic | GA for Dataverse plug-ins only. |
| Certificate-based | Production S2S where secrets are insufficient | Access: ~60-75 min | No refresh; new assertion per request | More secure than client secrets. Store certs in Azure Key Vault. |
Authentication Gotchas
- ADAL is dead: MSAL is now required.
CrmServiceClientused ADAL internally; migrate toServiceClientwhich uses MSAL. [src1] - Scopes differ by client type: Public clients use
{org_url}/user_impersonation; confidential clients (S2S) use{org_url}/.default. Wrong scope causes "invalid_grant" errors. [src1] - Application users don't need Dataverse API permissions in Entra ID: For S2S, security roles in Dataverse control access, not Entra ID API permissions. [src2, src7]
- Token lifetime is configurable: Default 60-75 min; min 10 min, max 1 day via Entra ID token lifetime policies. [src3]
- Multi-tenant registrations require per-tenant admin consent. [src2]
Constraints
- Application users cannot log in interactively - S2S only, cannot use D365 web UI. [src7, src8]
- Client secrets expire after max 2 years; certificates after max 3 years. Build automated rotation. [src6]
- Application users draw from tenant's non-licensed user pool (250K requests/24h); exceeding returns 429 errors. [src8]
- ROPC flow does not support MFA - if account has MFA enabled, username/password auth fails with AADSTS50076. [src1]
- Managed identity is limited to Dataverse plug-in scenarios only - cannot be used for general API access from Azure Functions/App Services. [src4]
- On-premises D365 Customer Engagement uses claims-based auth (IFD/ADFS), not OAuth 2.0. [src1]
Integration Pattern Decision Tree
START - Authenticate with Dynamics 365 Dataverse
|-- What type of application?
| |-- Server/daemon (no user present)
| | |-- Running on Azure?
| | | |-- YES, Dataverse plug-in accessing Azure resources --> Managed Identity
| | | |-- YES, general API access --> Client Credentials + Certificate (Key Vault)
| | | |-- NO (on-premise server) --> Client Credentials + Certificate or Secret
| | |-- Credential type?
| | |-- Production --> Certificate (more secure, longer lifetime)
| | |-- Dev/test --> Client Secret (simpler setup)
| |-- Interactive web application --> Authorization Code flow with PKCE
| |-- SPA (single-page app) --> Authorization Code + PKCE (public client)
| |-- Mobile / desktop app --> Authorization Code + PKCE with native redirect
| |-- Legacy (cannot be updated) --> ROPC (ONLY if MFA is disabled)
|-- Multi-tenant or single-tenant?
| |-- Single-tenant --> Register in your tenant only
| |-- Multi-tenant --> Each tenant admin must consent; use /common authority
|-- Token refresh strategy?
|-- S2S --> No refresh token; request new proactively before expiry
|-- Delegated --> Use MSAL cache; refresh tokens last up to 90 days
|-- SPA --> Refresh tokens expire in 24h; re-authenticate interactively
Quick Reference
| Operation | Endpoint / Config | Value | Notes |
|---|---|---|---|
| Token endpoint (v2.0) | https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token | POST | Use v2.0 endpoint for MSAL |
| Scope (confidential client) | {org_url}/.default | e.g., https://myorg.crm.dynamics.com/.default | S2S client credentials |
| Scope (public client) | {org_url}/user_impersonation | e.g., https://myorg.crm.dynamics.com/user_impersonation | Delegated permissions |
| API base URL | https://{org}.crm.dynamics.com/api/data/v9.2/ | GET/POST/PATCH/DELETE | Regional suffixes: crm (NA), crm4 (EMEA), crm5 (APAC) |
| WhoAmI test | GET /api/data/v9.2/WhoAmI | Returns UserId, BusinessUnitId, OrganizationId | Best endpoint to verify auth |
| Application user creation | Power Platform Admin Center > Environments > S2S | Bind app registration to Dataverse user | Assign security roles after creation |
| MSAL NuGet package | Microsoft.Identity.Client | Latest stable | Replaces deprecated ADAL |
| MSAL Python package | msal | Latest stable | pip install msal |
Step-by-Step Integration Guide
1. Register application in Microsoft Entra ID
Navigate to Azure Portal > Microsoft Entra ID > App registrations > New registration. Provide a name, select the appropriate account type, and register. Copy the Application (client) ID and Directory (tenant) ID. [src2]
# Record these values from the Overview page:
# - Application (client) ID: {your-client-id}
# - Directory (tenant) ID: {your-tenant-id}
# For multi-tenant: set Supported account types to "Accounts in any organizational directory"
Verify: App appears in the App registrations list in Entra ID.
2. Configure credentials (secret or certificate)
For client secret: Certificates & secrets > Client secrets > New client secret. Copy the Value immediately. For certificates: Upload .cer, .pem, or .crt under the Certificates tab. [src2, src5]
# Self-signed certificate (dev/test only):
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes \
-subj "/CN=D365Integration"
# Production: use Azure Key Vault
# az keyvault certificate create --vault-name MyVault --name D365Cert \
# --policy "$(az keyvault certificate get-default-policy)"
Verify: Secret or certificate thumbprint appears under Certificates & secrets.
3. Create application user in Dataverse
Go to Power Platform Admin Center > Environments > select environment > S2S > New app user. Select your app registration, choose business unit, assign security roles. [src2, src7]
Verify: In D365, Settings > Security > Users > "Application Users" view shows your app user.
4. Acquire access token and call API
Use MSAL to acquire a token, include as Bearer token in Authorization header. [src1]
import msal, requests
tenant_id = "YOUR_TENANT_ID"
client_id = "YOUR_CLIENT_ID"
client_secret = "YOUR_CLIENT_SECRET"
org_url = "https://yourorg.crm.dynamics.com"
app = msal.ConfidentialClientApplication(
client_id,
authority=f"https://login.microsoftonline.com/{tenant_id}",
client_credential=client_secret
)
result = app.acquire_token_for_client(scopes=[f"{org_url}/.default"])
if "access_token" in result:
headers = {
"Authorization": f"Bearer {result['access_token']}",
"OData-MaxVersion": "4.0",
"OData-Version": "4.0",
"Accept": "application/json"
}
response = requests.get(f"{org_url}/api/data/v9.2/WhoAmI", headers=headers)
print(response.json())
Verify: WhoAmI returns JSON with UserId, BusinessUnitId, OrganizationId.
Code Examples
Python: Client Credentials (S2S) with Certificate
# Input: tenant_id, client_id, certificate path, org_url
# Output: Access token acquired via certificate assertion
import msal, requests
tenant_id = "YOUR_TENANT_ID"
client_id = "YOUR_CLIENT_ID"
org_url = "https://yourorg.crm.dynamics.com"
with open("key.pem", "r") as f:
key_data = f.read()
app = msal.ConfidentialClientApplication(
client_id,
authority=f"https://login.microsoftonline.com/{tenant_id}",
client_credential={"private_key": key_data, "thumbprint": "CERT_THUMBPRINT_HEX"}
)
result = app.acquire_token_for_client(scopes=[f"{org_url}/.default"])
if "access_token" in result:
headers = {"Authorization": f"Bearer {result['access_token']}",
"OData-MaxVersion": "4.0", "OData-Version": "4.0", "Accept": "application/json"}
resp = requests.get(f"{org_url}/api/data/v9.2/accounts?$top=10&$select=name", headers=headers)
print(resp.json())
JavaScript/Node.js: Client Credentials with @azure/identity
// Input: AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET env vars
// Output: Authenticated Dataverse Web API call
// npm install @azure/identity node-fetch@2
const { ClientSecretCredential } = require("@azure/identity");
const fetch = require("node-fetch");
const orgUrl = "https://yourorg.crm.dynamics.com";
const credential = new ClientSecretCredential(
process.env.AZURE_TENANT_ID,
process.env.AZURE_CLIENT_ID,
process.env.AZURE_CLIENT_SECRET
);
async function callDataverse() {
const token = await credential.getToken(`${orgUrl}/.default`);
const response = await fetch(`${orgUrl}/api/data/v9.2/WhoAmI`, {
headers: { Authorization: `Bearer ${token.token}`,
"OData-MaxVersion": "4.0", "OData-Version": "4.0", "Accept": "application/json" }
});
console.log(await response.json());
}
callDataverse().catch(console.error);
C#/.NET: ServiceClient with Client Secret
// NuGet: Microsoft.PowerPlatform.Dataverse.Client
using Microsoft.PowerPlatform.Dataverse.Client;
using Microsoft.Crm.Sdk.Messages;
string connectionString = @"AuthType=ClientSecret;
Url=https://yourorg.crm.dynamics.com;
ClientId=YOUR_CLIENT_ID;
Secret=YOUR_CLIENT_SECRET;
RequireNewInstance=true";
using var service = new ServiceClient(connectionString);
if (service.IsReady) {
var response = (WhoAmIResponse)service.Execute(new WhoAmIRequest());
Console.WriteLine($"Connected as UserId: {response.UserId}");
}
cURL: Quick token acquisition and API test
# Acquire access token
TOKEN=$(curl -s -X POST \
"https://login.microsoftonline.com/TENANT_ID/oauth2/v2.0/token" \
-d "client_id=CLIENT_ID&client_secret=SECRET&scope=https://yourorg.crm.dynamics.com/.default&grant_type=client_credentials" \
| jq -r '.access_token')
# Test authentication
curl -s "https://yourorg.crm.dynamics.com/api/data/v9.2/WhoAmI" \
-H "Authorization: Bearer $TOKEN" -H "Accept: application/json" | jq .
Data Mapping
Authentication Configuration Reference
| Config Field | Client Credentials | Auth Code | Certificate | Managed Identity |
|---|---|---|---|---|
| grant_type | client_credentials | authorization_code | client_credentials | N/A (automatic) |
| scope | {org_url}/.default | {org_url}/user_impersonation | {org_url}/.default | {org_url}/.default |
| client_id | Required | Required | Required | Not needed |
| client_secret | Required | Optional (with PKCE) | Not used | Not needed |
| client_assertion_type | Not used | Not used | urn:ietf:params:oauth:client-assertion-type:jwt-bearer | Not used |
| redirect_uri | Not needed | Required | Not needed | Not needed |
Data Type Gotchas
- Regional API endpoints differ: NA uses crm.dynamics.com, EMEA uses crm4.dynamics.com, APAC uses crm5.dynamics.com. The scope URL must match the org's actual endpoint. [src1]
- Tenant ID vs Organization ID: Tenant ID (Entra ID) is for auth; Organization ID (Dataverse) identifies the D365 instance. Multi-org tenants have one tenant ID but multiple org IDs. [src1]
- Application ID URI auto-generated: When creating an application user, the URI is auto-populated. Do not manually modify it. [src7]
Error Handling & Failure Points
Common Error Codes
| Code | Meaning | Cause | Resolution |
|---|---|---|---|
| AADSTS700016 | Application not found | Wrong client ID or app not in target tenant | Verify client ID; for multi-tenant, ensure admin consent was granted |
| AADSTS7000215 | Invalid client secret | Secret expired or copied incorrectly | Rotate secret; copy the Value (not the ID) from Entra ID |
| AADSTS50076 | MFA required | Using ROPC with MFA-enabled account | Switch to auth code flow or use S2S with application user |
| AADSTS65001 | Consent not granted | Missing user or admin consent | Grant admin consent in Entra ID > API permissions |
| AADSTS700027 | Certificate validation failed | Wrong thumbprint, expired cert, or key mismatch | Verify SHA-1 thumbprint; ensure private key matches uploaded public key |
| 401 Unauthorized | Invalid/expired token | Token expired, malformed, or wrong audience | Verify scope matches org URL; acquire new token |
| 403 Forbidden | Insufficient privileges | Application user lacks security roles | Assign security roles via Power Platform Admin Center |
| 429 Too Many Requests | Rate limit exceeded | Too many API calls | Implement exponential backoff; respect Retry-After header |
Failure Points in Production
- Client secret expiration: Secrets expire silently (max 2 years). Fix:
Use Azure Key Vault with notification webhooks, or certificates with auto-rotation.[src6] - Token cache misuse: Requesting new tokens per API call wastes time and risks throttling. Fix:
Use MSAL's built-in token cache; acquire_token_for_client() caches automatically.[src1] - Wrong scope: Using user_impersonation with client credentials or .default with delegated flow. Fix:
S2S always uses {org_url}/.default; delegated uses {org_url}/user_impersonation.[src1] - Application user deleted in Dataverse: Tokens still issued by Entra ID but all API calls return 403. Fix:
Monitor application user status; set alerts for security role changes.[src7] - Conditional Access blocking service principals: Policies requiring compliant devices block S2S auth. Fix:
Exclude service principal from location/device-based Conditional Access policies.[src1]
Anti-Patterns
Wrong: Hardcoding credentials in source code
# BAD - credentials visible in version control
client_secret = "myS3cretV@lue!"
Correct: Use environment variables or key vault
# GOOD - credentials from environment or Azure Key Vault
import os
client_secret = os.environ["AZURE_CLIENT_SECRET"]
# Even better: DefaultAzureCredential chains managed identity, env vars, CLI
from azure.identity import DefaultAzureCredential
credential = DefaultAzureCredential()
Wrong: Requesting a new token for every API call
# BAD - wastes time, risks throttling
for record in records:
result = app.acquire_token_for_client(scopes=scope)
requests.post(url, headers={"Authorization": f"Bearer {result['access_token']}"}, json=record)
Correct: Use MSAL token cache, acquire once
# GOOD - MSAL caches internally, auto-refreshes near expiry
result = app.acquire_token_for_client(scopes=scope)
headers = {"Authorization": f"Bearer {result['access_token']}"}
for record in records:
requests.post(url, headers=headers, json=record)
Wrong: Using ROPC for production integrations
# BAD - breaks when MFA enabled, no Conditional Access support
result = app.acquire_token_by_username_password(scopes=scope, username="[email protected]", password="P@ss!")
Correct: Use client credentials with application user
# GOOD - S2S with application user, supports MFA orgs
app = msal.ConfidentialClientApplication(client_id, authority=authority, client_credential=client_secret)
result = app.acquire_token_for_client(scopes=[f"{org_url}/.default"])
Common Pitfalls
- v1.0 vs v2.0 token endpoint: v1.0 uses
resourceparam; v2.0 usesscope. MSAL uses v2.0. Mixing causes "unsupported_grant_type". Fix:Always use v2.0 with MSAL and scope={org_url}/.default.[src1] - Missing security roles on app user: Token works but every API call returns 403. Fix:
Assign at least one security role via Power Platform Admin Center.[src7] - Secret Value vs ID confusion: Portal shows "Secret ID" (GUID) and "Value" (actual secret). Using the ID as credential fails. Fix:
Copy the Value immediately after creation - shown only once.[src6] - Testing with admin, deploying with restricted user: Works in dev with System Administrator role, fails in prod. Fix:
Test with actual application user security role before deploying.[src7] - Token expiry in long-running processes: Batch jobs >1h use expired tokens. Fix:
MSAL's acquire_token_for_client auto-returns fresh tokens from cache.[src1] - Wrong regional endpoint: NA uses crm.dynamics.com, EMEA uses crm4.dynamics.com. Wrong region in scope causes API failures. Fix:
Use the org's actual URL from Power Platform Admin Center.[src1]
Diagnostic Commands
# Acquire token and inspect claims
TOKEN=$(curl -s -X POST \
"https://login.microsoftonline.com/TENANT_ID/oauth2/v2.0/token" \
-d "client_id=CLIENT_ID&client_secret=SECRET&scope=https://yourorg.crm.dynamics.com/.default&grant_type=client_credentials" \
| jq -r '.access_token')
# Decode token payload (base64) to check claims
echo $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq .
# Test authentication - WhoAmI
curl -s "https://yourorg.crm.dynamics.com/api/data/v9.2/WhoAmI" \
-H "Authorization: Bearer $TOKEN" -H "Accept: application/json" | jq .
# Check application user's security roles
curl -s "https://yourorg.crm.dynamics.com/api/data/v9.2/systemusers?\$filter=applicationid eq 'CLIENT_ID'&\$expand=systemuserroles_association(\$select=name)" \
-H "Authorization: Bearer $TOKEN" -H "Accept: application/json" | jq .
# Verify Entra ID app registration (requires Azure CLI)
az ad app show --id CLIENT_ID --query "{name:displayName,appId:appId}" -o table
# List secrets and certificates with expiry dates
az ad app credential list --id CLIENT_ID -o table
Version History & Compatibility
| Change | Date | Impact | Migration Notes |
|---|---|---|---|
| MSAL required (ADAL deprecated) | 2022-06 | Breaking | Replace ADAL with MSAL. CrmServiceClient -> ServiceClient. |
| Azure AD renamed to Entra ID | 2023-07 | Branding only | No code changes. Old URLs redirect. |
| Managed identity GA for plug-ins | 2024-11 | New feature | Plug-ins only. Uses federated identity credentials. |
| ServiceClient replaces CrmServiceClient | 2022-03 | Recommended | Same connection string format, MSAL internally. |
| v2.0 token endpoint recommended | 2020-06 | Best practice | Use scope instead of resource parameter. |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| S2S integrations with Dataverse-based D365 apps | Integrating with D365 Business Central | BC's own S2S flow (API.ReadWrite.All permissions) |
| Unattended daemon/service access to D365 data | Need user-context audit trail showing the actual human | Authorization code flow with delegated permissions |
| Want to avoid consuming a paid D365 license | Integrating with D365 Finance & Operations | F&O uses different resource URI and Entra ID config |
| Production integration requiring certificate security | Quick ad-hoc prototyping | Client secret with short expiry for dev/test |
| Azure-hosted plug-ins accessing Azure resources | Azure Functions calling Dataverse API directly | Client credentials (managed identity is plug-in only) |
Important Caveats
- D365 Business Central and D365 Finance & Operations have different authentication patterns and token endpoints - do not mix them with this guide. [src1]
- Token lifetime policies at the Entra ID tenant level affect all applications. Changes by another team can unexpectedly shorten your token lifetimes. [src3]
- Application users created in one Dataverse environment are not automatically available in other environments. Create separately per environment. [src7]
- New tenants may have "Security Defaults" enabled, which can interfere with service principal authentication. Verify Conditional Access exclusions. [src1]
- API request quotas for application users are pooled at the tenant level, not per-environment. Heavy usage in one environment affects another. [src8]