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 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 |
| 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 |
| 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 |
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. |
CrmServiceClient used ADAL internally; migrate to ServiceClient which uses MSAL. [src1]{org_url}/user_impersonation; confidential clients (S2S) use {org_url}/.default. Wrong scope causes "invalid_grant" errors. [src1]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
| 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 |
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.
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.
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.
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.
# 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())
// 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);
// 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}");
}
# 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 .
| 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 |
| 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 |
Use Azure Key Vault with notification webhooks, or certificates with auto-rotation. [src6]Use MSAL's built-in token cache; acquire_token_for_client() caches automatically. [src1]S2S always uses {org_url}/.default; delegated uses {org_url}/user_impersonation. [src1]Monitor application user status; set alerts for security role changes. [src7]Exclude service principal from location/device-based Conditional Access policies. [src1]# BAD - credentials visible in version control
client_secret = "myS3cretV@lue!"
# 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()
# 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)
# 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)
# BAD - breaks when MFA enabled, no Conditional Access support
result = app.acquire_token_by_username_password(scopes=scope, username="[email protected]", password="P@ss!")
# 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"])
resource param; v2.0 uses scope. MSAL uses v2.0. Mixing causes "unsupported_grant_type". Fix: Always use v2.0 with MSAL and scope={org_url}/.default. [src1]Assign at least one security role via Power Platform Admin Center. [src7]Copy the Value immediately after creation - shown only once. [src6]Test with actual application user security role before deploying. [src7]MSAL's acquire_token_for_client auto-returns fresh tokens from cache. [src1]Use the org's actual URL from Power Platform Admin Center. [src1]# 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
| 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. |
| 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) |