This card compares OAuth 2.0 implementations across the six dominant cloud ERP platforms as of March 2026. Each vendor has taken a fundamentally different approach -- from Salesforce's self-contained connected app model to Microsoft's centralized Entra ID identity platform. This card focuses exclusively on OAuth 2.0 grant types, token mechanics, and implementation patterns.
| System | Role | OAuth 2.0 Provider | Primary Server-to-Server Flow |
|---|---|---|---|
| Salesforce | CRM/Platform | Built-in (Connected Apps) | JWT Bearer |
| SAP S/4HANA Cloud | ERP | SAP BTP / XSUAA | Client Credentials via Comm Arrangement |
| Oracle Fusion Cloud ERP | ERP | OCI IAM (ex-IDCS) | Client Credentials (confidential app) |
| NetSuite | ERP | Built-in | Authorization Code + Refresh |
| Dynamics 365 | ERP/CRM | Microsoft Entra ID | Client Credentials |
| Workday | HCM/Finance | Built-in (API Clients) | Refresh Token (with ISU) |
Each ERP's OAuth 2.0 implementation determines which API surfaces you can access. Not all OAuth flows unlock all API surfaces.
| ERP | OAuth-Protected APIs | Non-OAuth APIs (legacy) | OAuth Required Since |
|---|---|---|---|
| Salesforce | REST, SOAP, Bulk, Streaming, Composite | Username-Password (deprecated) | Connected apps since 2013; JWT since 2016 |
| SAP S/4HANA Cloud | OData v4, SOAP, REST (via BTP) | Communication user + basic auth (deprecated) | 2020 (BTP mandatory for Public Cloud) |
| Oracle Fusion Cloud | REST, SOAP, BI Publisher, FBDI | Basic auth (deprecated for new integrations) | 2023 (OCI IAM mandatory) |
| NetSuite | REST, RESTlets, SuiteAnalytics Connect | TBA (OAuth 1.0) for SuiteTalk SOAP | Optional; TBA deprecated for new integrations 2027.1 |
| Dynamics 365 | Dataverse Web API (OData v4), custom APIs | S2S with Azure AD (same mechanism) | Always required (Azure AD since inception) |
| Workday | REST API, SOAP (WWS via OAuth) | ISU with basic auth (still supported) | Optional; recommended since v35.0+ |
| ERP | Token Endpoint Rate Limit | Token Request Method | Notes |
|---|---|---|---|
| Salesforce | 1,800 token requests/hour per connected app | POST to /services/oauth2/token | Shared across all flows [src1] |
| SAP S/4HANA Cloud | Fair-use / throttled per subaccount | POST to XSUAA /oauth/token | No hard limit; excessive requests return 429 [src2] |
| Oracle Fusion Cloud | 60 token requests/minute per app | POST to OCI IAM /oauth2/v1/token | Hard limit per confidential app [src3] |
| NetSuite | Included in API concurrency limits | POST to /services/rest/auth/oauth2/v1/token | Shares request budget [src5] |
| Dynamics 365 | No published per-app token limit | POST to login.microsoftonline.com | Entra ID throttles at tenant level [src4] |
| Workday | Not published | POST to /ccx/oauth2/{tenant}/token | Governed by overall API throughput [src6] |
| ERP | Daily API Limit | Window | Edition Differences |
|---|---|---|---|
| Salesforce | 100,000 (Enterprise), 5M (Unlimited) | 24h rolling | Developer: 15,000 |
| SAP S/4HANA Cloud | Fair-use / throttled | Per-request | No edition tiering |
| Oracle Fusion Cloud | Throttled per service | Per-request | Burst limits vary by API surface |
| NetSuite | Concurrency-based (max 10-25) | Per-request | SuiteCloud Plus adds concurrency |
| Dynamics 365 | 6,000 req/5min/user, 60,000/5min/org | 5-min window | Same for all editions |
| Workday | Not published; throttled | Per-request | Monitored by Workday ops |
| Grant Type | Salesforce | SAP S/4HANA | Oracle Fusion | NetSuite | D365 | Workday |
|---|---|---|---|---|---|---|
| Authorization Code | Yes | Yes (via BTP) | Yes | Yes | Yes | Yes |
| Client Credentials | No (use JWT) | Yes | Yes | No | Yes | No (use refresh) |
| JWT Bearer | Yes (primary S2S) | Yes (X.509) | No | No | Yes (cert-based) | No |
| Refresh Token | Yes (web server) | No (client creds) | Yes (7d default) | Yes (7d rolling) | No (client creds) | Yes (primary S2S) |
| Device Flow | Yes | No | No | No | Yes | No |
| SAML Bearer | Yes | Yes | Yes | No | No | No |
| ROPC (Username-Password) | Deprecated | No | No | No | Yes (not recommended) | No |
| ERP | Access Token Lifetime | Refresh Token Lifetime | Refresh Behavior | MFA Impact |
|---|---|---|---|---|
| Salesforce | Session timeout (default 2h, configurable) | Until revoked (web server flow) | New JWT per request for JWT bearer; refresh token for web server | MFA blocks ROPC; JWT/web server unaffected |
| SAP S/4HANA Cloud | 12 min (XSUAA default) | Not issued (client credentials) | Must request new token; short TTL by design | N/A -- communication user |
| Oracle Fusion Cloud | 1 hour (configurable) | 7 days (configurable up to 100 days) | Standard refresh; offline_access scope required | N/A for confidential apps |
| NetSuite | 60 min | 7 days (rolling) | Rolling -- new refresh token with each access token request | N/A for machine integrations |
| Dynamics 365 | 60-75 min | Not issued (client credentials) | Request new token using credentials; MSAL handles caching | N/A for app-only context |
| Workday | Short-lived (minutes) | Non-expiring (optional), or 14 days | Refresh returns new access + new refresh token | N/A -- ISU is system account |
| ERP | Scope Model | Example Scope | Granularity |
|---|---|---|---|
| Salesforce | Predefined scope strings | api, refresh_token, full | Coarse -- per API surface |
| SAP S/4HANA Cloud | Communication scenario scopes | Defined per comm arrangement | Fine -- per business scenario |
| Oracle Fusion Cloud | OCI IAM scopes | urn:opc:idm:__myscopes__ | Medium -- per application |
| NetSuite | REST/SuiteAnalytics scope | rest_webservices | Coarse -- per API surface |
| Dynamics 365 | Entra ID .default scope | https://org.crm.dynamics.com/.default | Coarse -- all permissions |
| Workday | Domain security via ISU | Implicit from ISU permissions | Fine -- per security domain |
https://{subdomain}.authentication.cert.{landscape}/oauth/token. [src2].default scope grants all configured permissions -- cannot request subset at token time. Client secrets max 24 months. [src4]START -- Choose OAuth 2.0 flow for ERP integration
|
+-- Is this server-to-server (no user interaction)?
| |
| +-- Salesforce? -> JWT Bearer Flow (connected app + X.509 cert)
| +-- SAP S/4HANA Cloud? -> Client Credentials via Communication Arrangement
| +-- Oracle Fusion Cloud? -> Client Credentials (confidential app in OCI IAM)
| +-- NetSuite? -> Auth Code + Refresh Token (REST only; SOAP needs TBA)
| +-- Dynamics 365? -> Client Credentials (Entra ID app registration)
| +-- Workday? -> Refresh Token Flow (API Client + ISU)
|
+-- Is this user-delegated (on behalf of user)?
| +-- Any ERP -> Authorization Code Flow
| +-- Salesforce -> Web Server Flow (with callback URL)
| +-- D365 -> Authorization Code + PKCE (public clients)
|
+-- Is this a CLI or device?
+-- Salesforce -> Device Flow
+-- Dynamics 365 -> Device Code Flow
+-- Others -> Not supported; use auth code with browser redirect
| Capability | Salesforce | SAP S/4HANA | Oracle Fusion | NetSuite | Dynamics 365 | Workday |
|---|---|---|---|---|---|---|
| OAuth Provider | Built-in (Connected Apps) | SAP BTP / XSUAA | OCI IAM (ex-IDCS) | Built-in | Microsoft Entra ID | Built-in (API Clients) |
| Primary S2S Grant | JWT Bearer | Client Credentials | Client Credentials | Auth Code + Refresh | Client Credentials | Refresh Token |
| Certificate Auth | X.509 on connected app | X.509 via mTLS | X.509 assertion | Not supported | Certificate on app reg | Not supported |
| Token Lifetime | Session (~2h) | 12 min | 1h (configurable) | 60 min | 60-75 min | Minutes |
| Refresh Token | Web server only | No (client creds) | Yes (7d default) | Yes (7d rolling) | No (client creds) | Yes (non-expiring opt.) |
| Scope Model | Coarse (api, full) | Per-scenario | Per-app / custom | Per-API-surface | .default (all-or-nothing) | ISU security domains |
| Admin Portal | Setup > Connected Apps | BTP Cockpit | OCI Console > Identity | Setup > OAuth 2.0 | Entra Admin Center | Workday > API Client |
| SDK/Library | jsforce, simple-salesforce | SAP Cloud SDK | OCI SDK | Standard HTTP | MSAL | Standard HTTP |
| Setup Complexity | Medium | High | Medium | Low | Low | Medium |
| PKCE Support | Yes | No | No | Yes | Yes | No |
Create a connected app with a digital certificate, sign a JWT assertion, and exchange for an access token. [src1]
# Input: Connected app consumer key, X.509 private key PEM, Salesforce username
# Output: Access token and instance_url
import jwt, requests, time
CONSUMER_KEY = "3MVG9..."
PRIVATE_KEY = open("server.key", "r").read()
USERNAME = "[email protected]"
LOGIN_URL = "https://login.salesforce.com"
claim = {
"iss": CONSUMER_KEY, "sub": USERNAME,
"aud": LOGIN_URL, "exp": int(time.time()) + 300
}
assertion = jwt.encode(claim, PRIVATE_KEY, algorithm="RS256")
response = requests.post(f"{LOGIN_URL}/services/oauth2/token", data={
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": assertion
})
token_data = response.json()
access_token = token_data["access_token"]
instance_url = token_data["instance_url"] # CRITICAL: use for all API calls
Verify: curl -H "Authorization: Bearer {token}" {instance_url}/services/data/v62.0/limits -> returns JSON with API limits
Request a token from the XSUAA endpoint using service key credentials. [src2]
# Input: XSUAA clientid, clientsecret, token URL from service key
# Output: Access token (12-minute TTL)
import requests
response = requests.post(TOKEN_URL, data={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET
})
access_token = response.json()["access_token"]
# Token expires in ~720 seconds (12 min) -- cache and refresh proactively
Verify: curl -H "Authorization: Bearer {token}" https://{host}/sap/opu/odata4/.../$metadata -> returns OData metadata
Register a confidential application in OCI IAM, then request a token. [src3]
# Input: OCI IAM client_id, client_secret, token endpoint
# Output: Access token (1-hour default)
response = requests.post(TOKEN_URL, data={
"grant_type": "client_credentials",
"scope": "urn:opc:idm:__myscopes__"
}, auth=(CLIENT_ID, CLIENT_SECRET))
access_token = response.json()["access_token"]
Verify: curl -H "Authorization: Bearer {token}" https://{host}/fscmRestApi/resources/11.13.18.05/invoices?limit=1 -> returns invoice JSON
Register an app in Entra ID, grant D365 API permissions, request a token. [src4]
# Input: Entra tenant_id, client_id, client_secret, D365 org URL
# Output: Access token (60-75 minute lifetime)
response = requests.post(
f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token",
data={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"scope": f"{D365_URL}/.default"
}
)
access_token = response.json()["access_token"]
# No refresh token -- request new token when expired
Verify: curl -H "Authorization: Bearer {token}" {D365_URL}/api/data/v9.2/WhoAmI -> returns system user ID
Enable OAuth 2.0 in SuiteCloud, create integration record, use refresh tokens for ongoing access. [src5]
# Input: client_id, client_secret, refresh_token
# Output: New access + refresh token (rolling refresh)
response = requests.post(TOKEN_URL, data={
"grant_type": "refresh_token",
"refresh_token": REFRESH_TOKEN,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET
})
data = response.json()
access_token = data["access_token"]
new_refresh_token = data["refresh_token"] # MUST store -- old token now invalid
Verify: curl -H "Authorization: Bearer {token}" https://{id}.suitetalk.api.netsuite.com/services/rest/record/v1/customer?limit=1 -> returns customer JSON
Register API client, link to ISU, generate non-expiring refresh token, exchange for access tokens. [src6]
# Input: client_id, client_secret, refresh_token, tenant
# Output: Access token + new refresh token
response = requests.post(
f"https://wd2-impl-services1.workday.com/ccx/oauth2/{TENANT}/token",
data={
"grant_type": "refresh_token",
"refresh_token": REFRESH_TOKEN,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET
}
)
data = response.json()
access_token = data["access_token"]
new_refresh_token = data["refresh_token"] # Store for next exchange
Verify: curl -H "Authorization: Bearer {token}" https://wd2-impl-services1.workday.com/ccx/api/v1/{tenant}/workers?limit=1 -> returns worker data
# --- Salesforce JWT Bearer ---
curl -X POST https://login.salesforce.com/services/oauth2/token \
-d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
-d "assertion=${JWT_ASSERTION}"
# --- SAP S/4HANA Client Credentials ---
curl -X POST "${XSUAA_TOKEN_URL}" \
-u "${CLIENT_ID}:${CLIENT_SECRET}" \
-d "grant_type=client_credentials"
# --- Oracle Fusion Client Credentials ---
curl -X POST "${OCI_IAM_TOKEN_URL}" \
-u "${CLIENT_ID}:${CLIENT_SECRET}" \
-d "grant_type=client_credentials" \
-d "scope=urn:opc:idm:__myscopes__"
# --- Dynamics 365 Client Credentials ---
curl -X POST "https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token" \
-d "grant_type=client_credentials" \
-d "client_id=${CLIENT_ID}" \
-d "client_secret=${CLIENT_SECRET}" \
-d "scope=${D365_URL}/.default"
# --- NetSuite OAuth 2.0 Refresh ---
curl -X POST "https://${ACCOUNT_ID}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token" \
-d "grant_type=refresh_token" \
-d "refresh_token=${REFRESH_TOKEN}" \
-d "client_id=${CLIENT_ID}" \
-d "client_secret=${CLIENT_SECRET}"
# --- Workday Refresh Token ---
curl -X POST "https://wd2-impl-services1.workday.com/ccx/oauth2/${TENANT}/token" \
-d "grant_type=refresh_token" \
-d "refresh_token=${REFRESH_TOKEN}" \
-d "client_id=${CLIENT_ID}" \
-d "client_secret=${CLIENT_SECRET}"
| Error Code | ERP(s) | Meaning | Resolution |
|---|---|---|---|
invalid_grant | All | Token/assertion rejected | Regenerate assertion or refresh token; check cert validity |
invalid_client | All | Client ID/secret wrong | Verify credentials match registered application |
unauthorized_client | Salesforce, D365 | App not authorized for this grant | Enable the specific OAuth flow in app config |
invalid_scope | Oracle, D365 | Scope not allowed | Add scope in OCI IAM or Entra admin center |
| 429 | SAP, Oracle | Rate limit on token endpoint | Cache tokens; exponential backoff |
INVALID_LOGIN | Salesforce | JWT subject user inactive | Verify integration user is active with API access |
expired_token | NetSuite, Workday | Refresh token expired | Re-authorize via authorization code flow |
Implement certificate rotation monitoring with alerts at 30, 7, and 1 day before expiry. [src1, src2]Use transactional storage -- write new refresh token before using the access token. [src5, src6]Proactively refresh token before each batch in middleware. [src2]Use certificate auth for production, or automate rotation with Azure Key Vault. [src4]Use OCI IAM domain URL format and test during migration window. [src3]Never link ISUs to human employees; create dedicated service ISUs. [src6]# BAD -- tokens and secrets in plaintext .env or config.json
config = {
"salesforce_token": "00D5e000000XXXX!AR...",
"sap_client_secret": "xsuaa-secret-value-here",
"d365_client_secret": "A1bC2dE3f..."
}
# Committed to Git, visible in CI logs, no rotation tracking
# GOOD -- secrets from vault with automatic rotation
import boto3 # or azure.keyvault, hashicorp vault
secrets_client = boto3.client("secretsmanager")
def get_secret(name):
return secrets_client.get_secret_value(SecretId=name)["SecretString"]
salesforce_key = get_secret("erp/salesforce/private_key")
sap_secret = get_secret("erp/sap/client_secret")
# Rotation policies enforced at vault level
# BAD -- token request per API call wastes quota and adds latency
def get_data(query):
token = get_new_token() # ~200ms per call
return requests.get(url, headers={"Authorization": f"Bearer {token}"})
# GOOD -- cache token, refresh before expiry
class TokenCache:
def __init__(self):
self._token, self._expires_at = None, 0
def get_token(self):
if time.time() > (self._expires_at - 60):
self._token, self._expires_at = self._fetch_new_token()
return self._token
# BAD -- breaks across environments, pod migrations
API_URL = "https://na139.salesforce.com/services/data/v62.0/query"
# GOOD -- dynamic instance URL from OAuth response
data = requests.post(login_url + "/services/oauth2/token", data={...}).json()
instance_url = data["instance_url"] # Salesforce tells you where to call
Abstract token acquisition per ERP using adapter pattern. [src1, src6]Always use instance_url from each token response. [src1]Always respect expires_in from token response. [src2]Use certificate-based auth (client_assertion) in production. [src4]Persist new refresh token transactionally before making API calls. [src5, src7]Always create dedicated ISUs for each integration. [src6]# --- Salesforce: Test JWT Bearer token ---
python3 -c "
import jwt, time, requests
claim = {'iss':'${KEY}','sub':'${USER}','aud':'https://login.salesforce.com','exp':int(time.time())+300}
a = jwt.encode(claim, open('server.key').read(), algorithm='RS256')
r = requests.post('https://login.salesforce.com/services/oauth2/token',
data={'grant_type':'urn:ietf:params:oauth:grant-type:jwt-bearer','assertion':a})
print(r.status_code, r.json().get('access_token','ERROR')[:20]+'...')
"
# --- SAP: Test XSUAA client credentials ---
curl -s -o /dev/null -w "%{http_code}" \
-u "${CLIENT_ID}:${CLIENT_SECRET}" \
-d "grant_type=client_credentials" "${XSUAA_TOKEN_URL}"
# Expected: 200
# --- Oracle: Test OCI IAM token ---
curl -s -o /dev/null -w "%{http_code}" \
-u "${CLIENT_ID}:${CLIENT_SECRET}" \
-d "grant_type=client_credentials&scope=urn:opc:idm:__myscopes__" \
"${OCI_IAM_TOKEN_URL}"
# Expected: 200
# --- Dynamics 365: Test Entra ID ---
curl -s -o /dev/null -w "%{http_code}" \
-d "grant_type=client_credentials&client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}&scope=${D365_URL}/.default" \
"https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token"
# Expected: 200
# --- NetSuite: Test OAuth 2.0 refresh ---
curl -s -o /dev/null -w "%{http_code}" \
-d "grant_type=refresh_token&refresh_token=${REFRESH_TOKEN}&client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}" \
"https://${ACCOUNT_ID}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token"
# Expected: 200
# --- Workday: Test refresh token ---
curl -s -o /dev/null -w "%{http_code}" \
-d "grant_type=refresh_token&refresh_token=${REFRESH_TOKEN}&client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}" \
"https://wd2-impl-services1.workday.com/ccx/oauth2/${TENANT}/token"
# Expected: 200
| ERP | OAuth Milestone | Date | Status | Notes |
|---|---|---|---|---|
| Salesforce | JWT Bearer flow GA | 2016 | Current | No breaking changes since introduction |
| Salesforce | Device flow GA | 2020 | Current | CLI/IoT devices |
| SAP S/4HANA Cloud | XSUAA OAuth mandatory (Public Cloud) | 2020 | Current | Communication arrangements required |
| SAP | X.509 mTLS for XSUAA | 2022 | Current | Alternative to client secret |
| Oracle Fusion | IDCS OAuth GA | 2018 | Migrating to OCI IAM | IDCS merged into OCI IAM 2023 |
| Oracle Fusion | Basic auth deprecated (new integrations) | 2023 | Current | Existing basic auth still works |
| NetSuite | OAuth 2.0 GA | 2021 | Current | REST APIs only |
| NetSuite | TBA deprecated for new integrations | 2027.1 | Planned | Existing TBA continues |
| Dynamics 365 | Entra ID rebrand (from Azure AD) | 2023 | Current | No protocol changes |
| Dynamics 365 | Federated identity credentials | 2024 | Current | Cross-cloud workloads (GCP, AWS) |
| Workday | OAuth 2.0 API clients GA | 2020 | Current | Requires ISU linkage |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Comparing OAuth 2.0 flows across multiple ERPs | Need detailed single-ERP OAuth walkthrough | System-specific auth card |
| Deciding which grant type for server-to-server ERP integration | Need non-OAuth auth comparison (SAML, certs, basic) | erp-authentication-comparison |
| Building a universal integration adapter for multiple ERPs | Need rate limits or API capabilities (not auth) | System-specific API capability cards |
| Planning certificate/secret rotation across ERPs | Need SSO/SAML for end-user login | Vendor SSO documentation |
| Capability | Salesforce | SAP S/4HANA | Oracle Fusion | NetSuite | Dynamics 365 | Workday | Notes |
|---|---|---|---|---|---|---|---|
| OAuth Provider | Built-in | SAP BTP/XSUAA | OCI IAM | Built-in | Entra ID | Built-in | SF, NS, WD self-contained |
| S2S Grant | JWT Bearer | Client Credentials | Client Credentials | Auth Code+Refresh | Client Credentials | Refresh Token | No universal grant |
| Token TTL | ~2h (session) | 12 min | 1h | 60 min | 60-75 min | Minutes | SAP shortest by far |
| Refresh Token | Web server only | N/A | 7d default | 7d rolling | N/A | Non-expiring opt. | Rolling (NS/WD) vs static |
| Certificate Auth | X.509 on app | X.509 mTLS | X.509 assertion | No | Certificate on app | No | SAP requires mTLS endpoint |
| Setup Complexity | Medium | High | Medium | Low | Low | Medium | SAP highest (BTP dependency) |
| SDK Support | jsforce | SAP Cloud SDK | OCI SDK | HTTP only | MSAL | HTTP only | MSAL best abstraction |
| Admin Consent | Profile pre-auth | Comm arrangement | IAM admin grant | Admin consent | Entra admin consent | ISU policies | Each model different |
| Multi-tenant | Per-org app | Per-subaccount | Per-IAM-domain | Per-account-id | Per-Entra-tenant | Per-tenant | URL patterns all differ |
| PKCE Support | Yes | No | No | Yes | Yes | No | Required for public clients |