OAuth 2.0 Implementation Across ERPs: Salesforce, SAP, Oracle, D365, NetSuite, Workday

Type: ERP Integration Systems: Salesforce, SAP S/4HANA, Oracle Fusion, NetSuite, Dynamics 365, Workday Confidence: 0.87 Sources: 8 Verified: 2026-03-07 Freshness: evolving

TL;DR

System Profile

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.

SystemRoleOAuth 2.0 ProviderPrimary Server-to-Server Flow
SalesforceCRM/PlatformBuilt-in (Connected Apps)JWT Bearer
SAP S/4HANA CloudERPSAP BTP / XSUAAClient Credentials via Comm Arrangement
Oracle Fusion Cloud ERPERPOCI IAM (ex-IDCS)Client Credentials (confidential app)
NetSuiteERPBuilt-inAuthorization Code + Refresh
Dynamics 365ERP/CRMMicrosoft Entra IDClient Credentials
WorkdayHCM/FinanceBuilt-in (API Clients)Refresh Token (with ISU)

API Surfaces & Capabilities

Each ERP's OAuth 2.0 implementation determines which API surfaces you can access. Not all OAuth flows unlock all API surfaces.

ERPOAuth-Protected APIsNon-OAuth APIs (legacy)OAuth Required Since
SalesforceREST, SOAP, Bulk, Streaming, CompositeUsername-Password (deprecated)Connected apps since 2013; JWT since 2016
SAP S/4HANA CloudOData v4, SOAP, REST (via BTP)Communication user + basic auth (deprecated)2020 (BTP mandatory for Public Cloud)
Oracle Fusion CloudREST, SOAP, BI Publisher, FBDIBasic auth (deprecated for new integrations)2023 (OCI IAM mandatory)
NetSuiteREST, RESTlets, SuiteAnalytics ConnectTBA (OAuth 1.0) for SuiteTalk SOAPOptional; TBA deprecated for new integrations 2027.1
Dynamics 365Dataverse Web API (OData v4), custom APIsS2S with Azure AD (same mechanism)Always required (Azure AD since inception)
WorkdayREST API, SOAP (WWS via OAuth)ISU with basic auth (still supported)Optional; recommended since v35.0+

Rate Limits & Quotas

Token Endpoint Limits

ERPToken Endpoint Rate LimitToken Request MethodNotes
Salesforce1,800 token requests/hour per connected appPOST to /services/oauth2/tokenShared across all flows [src1]
SAP S/4HANA CloudFair-use / throttled per subaccountPOST to XSUAA /oauth/tokenNo hard limit; excessive requests return 429 [src2]
Oracle Fusion Cloud60 token requests/minute per appPOST to OCI IAM /oauth2/v1/tokenHard limit per confidential app [src3]
NetSuiteIncluded in API concurrency limitsPOST to /services/rest/auth/oauth2/v1/tokenShares request budget [src5]
Dynamics 365No published per-app token limitPOST to login.microsoftonline.comEntra ID throttles at tenant level [src4]
WorkdayNot publishedPOST to /ccx/oauth2/{tenant}/tokenGoverned by overall API throughput [src6]

Rolling / Daily Limits

ERPDaily API LimitWindowEdition Differences
Salesforce100,000 (Enterprise), 5M (Unlimited)24h rollingDeveloper: 15,000
SAP S/4HANA CloudFair-use / throttledPer-requestNo edition tiering
Oracle Fusion CloudThrottled per servicePer-requestBurst limits vary by API surface
NetSuiteConcurrency-based (max 10-25)Per-requestSuiteCloud Plus adds concurrency
Dynamics 3656,000 req/5min/user, 60,000/5min/org5-min windowSame for all editions
WorkdayNot published; throttledPer-requestMonitored by Workday ops

Authentication

OAuth 2.0 Grant Types by ERP

Grant TypeSalesforceSAP S/4HANAOracle FusionNetSuiteD365Workday
Authorization CodeYesYes (via BTP)YesYesYesYes
Client CredentialsNo (use JWT)YesYesNoYesNo (use refresh)
JWT BearerYes (primary S2S)Yes (X.509)NoNoYes (cert-based)No
Refresh TokenYes (web server)No (client creds)Yes (7d default)Yes (7d rolling)No (client creds)Yes (primary S2S)
Device FlowYesNoNoNoYesNo
SAML BearerYesYesYesNoNoNo
ROPC (Username-Password)DeprecatedNoNoNoYes (not recommended)No

Token Lifetime & Refresh Behavior

ERPAccess Token LifetimeRefresh Token LifetimeRefresh BehaviorMFA Impact
SalesforceSession timeout (default 2h, configurable)Until revoked (web server flow)New JWT per request for JWT bearer; refresh token for web serverMFA blocks ROPC; JWT/web server unaffected
SAP S/4HANA Cloud12 min (XSUAA default)Not issued (client credentials)Must request new token; short TTL by designN/A -- communication user
Oracle Fusion Cloud1 hour (configurable)7 days (configurable up to 100 days)Standard refresh; offline_access scope requiredN/A for confidential apps
NetSuite60 min7 days (rolling)Rolling -- new refresh token with each access token requestN/A for machine integrations
Dynamics 36560-75 minNot issued (client credentials)Request new token using credentials; MSAL handles cachingN/A for app-only context
WorkdayShort-lived (minutes)Non-expiring (optional), or 14 daysRefresh returns new access + new refresh tokenN/A -- ISU is system account

Scopes Comparison

ERPScope ModelExample ScopeGranularity
SalesforcePredefined scope stringsapi, refresh_token, fullCoarse -- per API surface
SAP S/4HANA CloudCommunication scenario scopesDefined per comm arrangementFine -- per business scenario
Oracle Fusion CloudOCI IAM scopesurn:opc:idm:__myscopes__Medium -- per application
NetSuiteREST/SuiteAnalytics scoperest_webservicesCoarse -- per API surface
Dynamics 365Entra ID .default scopehttps://org.crm.dynamics.com/.defaultCoarse -- all permissions
WorkdayDomain security via ISUImplicit from ISU permissionsFine -- per security domain

Authentication Gotchas

Constraints

Integration Pattern Decision Tree

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

Quick Reference

CapabilitySalesforceSAP S/4HANAOracle FusionNetSuiteDynamics 365Workday
OAuth ProviderBuilt-in (Connected Apps)SAP BTP / XSUAAOCI IAM (ex-IDCS)Built-inMicrosoft Entra IDBuilt-in (API Clients)
Primary S2S GrantJWT BearerClient CredentialsClient CredentialsAuth Code + RefreshClient CredentialsRefresh Token
Certificate AuthX.509 on connected appX.509 via mTLSX.509 assertionNot supportedCertificate on app regNot supported
Token LifetimeSession (~2h)12 min1h (configurable)60 min60-75 minMinutes
Refresh TokenWeb server onlyNo (client creds)Yes (7d default)Yes (7d rolling)No (client creds)Yes (non-expiring opt.)
Scope ModelCoarse (api, full)Per-scenarioPer-app / customPer-API-surface.default (all-or-nothing)ISU security domains
Admin PortalSetup > Connected AppsBTP CockpitOCI Console > IdentitySetup > OAuth 2.0Entra Admin CenterWorkday > API Client
SDK/Libraryjsforce, simple-salesforceSAP Cloud SDKOCI SDKStandard HTTPMSALStandard HTTP
Setup ComplexityMediumHighMediumLowLowMedium
PKCE SupportYesNoNoYesYesNo

Step-by-Step Integration Guide

1. Salesforce: Obtain Token via JWT Bearer Flow

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

2. SAP S/4HANA: Obtain Token via Client Credentials

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

3. Oracle Fusion Cloud: Client Credentials via OCI IAM

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

4. Dynamics 365: Entra ID Client Credentials

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

5. NetSuite: OAuth 2.0 Authorization Code + Refresh

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

6. Workday: Refresh Token Flow with ISU

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

Code Examples

cURL: Token Acquisition for Each ERP

# --- 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 Handling & Failure Points

Common Error Codes

Error CodeERP(s)MeaningResolution
invalid_grantAllToken/assertion rejectedRegenerate assertion or refresh token; check cert validity
invalid_clientAllClient ID/secret wrongVerify credentials match registered application
unauthorized_clientSalesforce, D365App not authorized for this grantEnable the specific OAuth flow in app config
invalid_scopeOracle, D365Scope not allowedAdd scope in OCI IAM or Entra admin center
429SAP, OracleRate limit on token endpointCache tokens; exponential backoff
INVALID_LOGINSalesforceJWT subject user inactiveVerify integration user is active with API access
expired_tokenNetSuite, WorkdayRefresh token expiredRe-authorize via authorization code flow

Failure Points in Production

Anti-Patterns

Wrong: Storing OAuth tokens in plaintext config files

# 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

Correct: Use a secrets manager with rotation support

# 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

Wrong: Requesting a new token for every API call

# 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}"})

Correct: Cache tokens and refresh proactively

# 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

Wrong: Hardcoding ERP instance URLs

# BAD -- breaks across environments, pod migrations
API_URL = "https://na139.salesforce.com/services/data/v62.0/query"

Correct: Parse instance_url from token response

# 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

Common Pitfalls

Diagnostic Commands

# --- 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

Version History & Compatibility

ERPOAuth MilestoneDateStatusNotes
SalesforceJWT Bearer flow GA2016CurrentNo breaking changes since introduction
SalesforceDevice flow GA2020CurrentCLI/IoT devices
SAP S/4HANA CloudXSUAA OAuth mandatory (Public Cloud)2020CurrentCommunication arrangements required
SAPX.509 mTLS for XSUAA2022CurrentAlternative to client secret
Oracle FusionIDCS OAuth GA2018Migrating to OCI IAMIDCS merged into OCI IAM 2023
Oracle FusionBasic auth deprecated (new integrations)2023CurrentExisting basic auth still works
NetSuiteOAuth 2.0 GA2021CurrentREST APIs only
NetSuiteTBA deprecated for new integrations2027.1PlannedExisting TBA continues
Dynamics 365Entra ID rebrand (from Azure AD)2023CurrentNo protocol changes
Dynamics 365Federated identity credentials2024CurrentCross-cloud workloads (GCP, AWS)
WorkdayOAuth 2.0 API clients GA2020CurrentRequires ISU linkage

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Comparing OAuth 2.0 flows across multiple ERPsNeed detailed single-ERP OAuth walkthroughSystem-specific auth card
Deciding which grant type for server-to-server ERP integrationNeed non-OAuth auth comparison (SAML, certs, basic)erp-authentication-comparison
Building a universal integration adapter for multiple ERPsNeed rate limits or API capabilities (not auth)System-specific API capability cards
Planning certificate/secret rotation across ERPsNeed SSO/SAML for end-user loginVendor SSO documentation

Cross-System Comparison

CapabilitySalesforceSAP S/4HANAOracle FusionNetSuiteDynamics 365WorkdayNotes
OAuth ProviderBuilt-inSAP BTP/XSUAAOCI IAMBuilt-inEntra IDBuilt-inSF, NS, WD self-contained
S2S GrantJWT BearerClient CredentialsClient CredentialsAuth Code+RefreshClient CredentialsRefresh TokenNo universal grant
Token TTL~2h (session)12 min1h60 min60-75 minMinutesSAP shortest by far
Refresh TokenWeb server onlyN/A7d default7d rollingN/ANon-expiring opt.Rolling (NS/WD) vs static
Certificate AuthX.509 on appX.509 mTLSX.509 assertionNoCertificate on appNoSAP requires mTLS endpoint
Setup ComplexityMediumHighMediumLowLowMediumSAP highest (BTP dependency)
SDK SupportjsforceSAP Cloud SDKOCI SDKHTTP onlyMSALHTTP onlyMSAL best abstraction
Admin ConsentProfile pre-authComm arrangementIAM admin grantAdmin consentEntra admin consentISU policiesEach model different
Multi-tenantPer-org appPer-subaccountPer-IAM-domainPer-account-idPer-Entra-tenantPer-tenantURL patterns all differ
PKCE SupportYesNoNoYesYesNoRequired for public clients

Important Caveats

Related Units