OAuth 2.0 for Enterprise Integrations: Client Credentials, JWT Bearer and Token Rotation

Madhu Nair
Practice Director
18 min read

OAuth 2.0 for enterprise integrations should use the client credentials grant for system-to-system calls, authenticated with private_key_jwt or mutual TLS instead of a shared secret, and the JWT bearer grant where Salesforce or Workday require it. Cache short-lived access tokens in the runtime, and give every secret, certificate and signing key a rotation procedure with an overlap window.

This guide is written for integration architects and platform owners whose interfaces already use OAuth 2.0, often with long-lived client secrets, and it extends our guide to securing integrations from OAuth to encryption. By the end, you can choose a client authentication method per integration, build a concurrency-safe token client and rotate any credential without an outage.

Why does OAuth 2.0 for enterprise integrations break in production?

OAuth breaks in production integrations because credentials are managed as static configuration rather than as expiring assets. Secrets and certificates expire without warning, long-lived refresh tokens get copied between connectors, and token requests are coded without caching or locking, so a routine expiry becomes a failed payroll file or a stalled order sync.

The failure we see most often in integration estate reviews is a client secret that expires over a weekend. Every call returns 401, retries multiply the load, and the 401 storm buries the root cause. A close second is a certificate that expires because nobody owned renewal.

Refresh tokens fail quietly. A non-expiring refresh token copied into three connectors is three copies of a permanent credential, and revoking it breaks all three. Token endpoints also throttle, so per-worker token fetching gets rate limited when a batch starts.

Clock skew between a container and the authorization server makes short-lived assertions look expired or not yet valid. Secrets committed to source control turn every clone of the repository into a copy of the credential. The cost lands outside IT: a payroll file that never reaches the provider, or an order sync that stops at month end.

How does the client credentials grant work for machine-to-machine authentication?

The client credentials grant lets an integration obtain an access token on its own behalf, with no user involved. The client authenticates to the token endpoint, receives a short-lived access token for one resource and presents it to the API. Its security depends almost entirely on how the client proves its identity.

The OAuth 2.0 authorization framework in RFC 6749 defines this grant in Section 4.4, and Section 4.4.3 says a refresh token SHOULD NOT be included in the response.

With client_secret_basic or client_secret_post, the same static secret crosses the wire on every request. With private_key_jwt, the client sends a client assertion, which is a short-lived JWT signed with its private key and verified against the registered certificate.

The OAuth 2.0 Security Best Current Practice in RFC 9700 recommends asymmetric methods such as mutual TLS or private_key_jwt. It also says servers SHOULD use sender-constrained tokens, which are access tokens bound to a key the client holds, through mutual TLS (RFC 8705) or DPoP (RFC 9449).

Method Client holds Crosses the wire Rotation effort Replay resistance Platform examples
client_secret_basic or client_secret_post Shared secret The secret Low Low Microsoft Entra ID secrets; cs in an Infor .ionapi file
private_key_jwt Private key and certificate Assertion valid for minutes Medium High Microsoft Entra ID certificate credentials
Mutual TLS (RFC 8705) Private key and certificate Proof of key possession in TLS Medium to high High; tokens can be certificate-bound Servers implementing RFC 8705
Workload identity federation No stored secret Assertion from another identity provider Lowest High Microsoft Entra ID with Kubernetes

Below is private_key_jwt against Microsoft Entra ID, using the documented parameters for certificate credentials in Microsoft Entra ID. The scope is the resource identifier plus .default, and all scopes must target one resource.

POST /{tenant-id}/oauth2/v2.0/token HTTP/1.1
Host: login.microsoftonline.com
Content-Type: application/x-www-form-urlencoded

scope=https%3A%2F%2Fgraph.microsoft.com%2F.default
&client_id={client-id}
&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer
&client_assertion={signed-client-assertion}
&grant_type=client_credentials

The assertion header carries alg PS256, typ JWT and x5t#S256, the certificate’s SHA-256 thumbprint. Claims set aud to the tenant token endpoint, iss and sub to the client ID, plus jti, nbf, iat and an exp that Microsoft advises keeping within 5 to 10 minutes of nbf. The response shows expires_in of 3599 seconds and no refresh token.

A shared secret remains acceptable for low-risk internal APIs when it is vaulted and expires within weeks. Mutual TLS is often impractical because some SaaS token endpoints do not support it, so private_key_jwt is the practical default.

When should an integration use the JWT bearer grant instead?

Use the JWT bearer grant when the target platform requires it or when the integration must act as a specific integration user, as Salesforce and Workday document for server-to-server access. Here the signed JWT is the authorization grant itself, not just proof of the client’s identity, and that difference decides how you scope it.

The JWT profile for OAuth 2.0 in RFC 7523 defines two uses. For client authentication (Section 2.2), the JWT travels as client_assertion with client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer beside a grant such as client_credentials. As an authorization grant (Section 2.1), it travels in the assertion parameter with grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer and replaces the grant.

Both uses require iss, sub, aud and exp, which is why teams conflate them. The test: if the JWT replaces client_secret, it is private_key_jwt; if it replaces the grant, it is the JWT bearer grant.

The Salesforce OAuth 2.0 JWT Bearer Flow for server-to-server integration sets iss to the Salesforce connected app client ID, sub to the Salesforce username, aud to the authorization server such as login.salesforce.com, and exp. The JWT is signed with RSA SHA256 using the private key matching the X509 certificate on the connected app. Salesforce allows 3 minutes of clock skew and never issues a refresh token here.

Each part below is base64url-encoded before signing.

{
  "alg": "RS256"
}
{
  "iss": "{connected-app-client-id}",
  "sub": "svc.order-sync@example.com",
  "aud": "https://login.salesforce.com",
  "exp": 1793000180
}

Workday’s Register API Client task offers a Client Grant Type of JWT Bearer Grant with an x509 certificate and a Workday Integration System User. The public key is registered in Workday, the private key stays with the caller, and its Data Cloud documentation shows the token endpoint pattern /ccx/oauth2/{tenant}/token on the tenant host.

The sub claim, or the user bound to the API client, decides whose permissions the integration inherits, so that user’s access is the blast radius if the key leaks. Give each integration a dedicated, least-privilege user, and never assert a named employee.

How do Workday, Salesforce, Microsoft Entra ID, Infor ION and MuleSoft implement these flows?

Microsoft Entra ID uses client credentials with a secret, certificate or federated assertion; Salesforce and Workday use the JWT bearer grant; Infor ION API Gateway packages client and service account credentials in an .ionapi file; and MuleSoft Anypoint Platform validates JWTs at the gateway.

MuleSoft checks signatures against a JSON Web Key Set (JWKS), which is the published document listing an issuer’s public signing keys. A kid is the key ID on each key, which the token header names so the verifier picks the right one.

Platform System-to-system grant or client authentication Credential the integration holds Refresh token behavior Rotation consideration
Microsoft Entra ID Client credentials; secret, certificate or federation Secret, private key or nothing None issued Azure Key Vault autorotation, partnered CAs only
Salesforce JWT bearer grant, Salesforce connected app Private key for the app certificate Never issued Overlap certificates on the connected app
Workday JWT Bearer Grant, Register API Client (Data Cloud documentation) x509 private key Non-Expiring Refresh Tokens option Rotate and revoke long-lived refresh tokens
Infor ION API Gateway Backend Service authorized app .ionapi file: ci, cs, saak, sask Issue Refresh Tokens option Vault the whole .ionapi file
MuleSoft Anypoint Platform JWT Validation policy, inbound None; validates others’ tokens Not applicable JWKS cached 60 minutes by default

In Workday, review the Non-Expiring Refresh Tokens check box in Register API Client for Integrations first, because a refresh token that never expires is a permanent credential. Integration System User setup and Workday JWT validation are covered in our guide to implementing Workday API security.

In Infor ION API Gateway, Backend Service apps live under OS, API Gateway, Authorized Apps, and credentials download as an .ionapi file after you create a service account. Our guide to security and access in Infor OS ION covers the wider controls.

How to implement token acquisition and caching in an integration runtime

Build one token client per credential. It signs a fresh assertion, requests a token, caches it in memory and refreshes it before expiry. A lock makes concurrent workers share one request, an invalid token triggers one refresh and one retry, and the private key comes from a secret store, never the repository.

  • Resolve the private key at startup from a secret store reference, such as an AWS Secrets Manager secret ID. Secrets and private keys never live in the repository; version control practices for integration projects keep only references there.
  • Sign a fresh client assertion per request, with kid or x5t#S256 in the header, a unique jti and a 5-minute exp.
  • Cache the access token in process memory, never on disk.
  • Refresh at about 80 percent of expires_in, with jitter so workers started together do not refresh together.
  • Serialize refreshes behind a lock. Single-flight refresh means that when many threads need a new token at once, exactly one requests it and the rest reuse its result.
  • On a 401 with error=”invalid_token” in the WWW-Authenticate header, refresh once and retry once. If the retry fails, raise an alert instead of looping.
  • Log status codes, OAuth error codes and the client ID, never tokens, assertions or raw response bodies.

A token request is a production dependency, so it deserves the same caching, locking and alerting as any API call. The client below implements these steps for Microsoft Entra ID with PyJWT, requests and boto3.

"""OAuth 2.0 token client for machine-to-machine calls using private_key_jwt.

Dependencies: PyJWT[crypto], requests, boto3.
"""
import logging
import random
import threading
import time
import uuid
from typing import Optional

import boto3
import jwt
import requests

log = logging.getLogger("token_client")

ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"

class AuthFailure(Exception):
    """Raised when no token can be obtained or a refreshed token is still rejected."""

def load_private_key(secret_id: str) -> str:
    # The repository stores only the secret ID; the PEM key stays in the secret store.
    client = boto3.client("secretsmanager")
    return client.get_secret_value(SecretId=secret_id)["SecretString"]

def raise_alert(message: str) -> None:
    # Wire this to your paging or monitoring tool.
    log.critical("ALERT: %s", message)

class TokenClient:
    def __init__(self, token_url: str, client_id: str, scope: str,
                 cert_thumbprint_s256: str, key_secret_id: str) -> None:
        self.token_url = token_url              # also the assertion audience
        self.client_id = client_id
        self.scope = scope                      # resource identifier + "/.default"
        self.thumbprint = cert_thumbprint_s256  # base64url SHA-256 thumbprint
        self._key = load_private_key(key_secret_id)
        self._lock = threading.Lock()
        self._token: Optional[str] = None
        self._refresh_at = 0.0                  # monotonic clock time

    def _build_assertion(self) -> str:
        now = int(time.time())
        claims = {
            "iss": self.client_id,
            "sub": self.client_id,
            "aud": self.token_url,
            "jti": str(uuid.uuid4()),
            "iat": now,
            "nbf": now,
            "exp": now + 300,                   # 5-minute assertion lifetime
        }
        headers = {"typ": "JWT", "x5t#S256": self.thumbprint}
        return jwt.encode(claims, self._key, algorithm="PS256", headers=headers)

    def _fetch_token(self) -> None:
        resp = requests.post(self.token_url, timeout=10, data={
            "grant_type": "client_credentials",
            "client_id": self.client_id,
            "scope": self.scope,
            "client_assertion_type": ASSERTION_TYPE,
            "client_assertion": self._build_assertion(),
        })
        if resp.status_code != 200:
            # Log the status only; error bodies can echo request details.
            log.warning("Token request failed for %s: HTTP %s",
                        self.client_id, resp.status_code)
            raise AuthFailure(f"token endpoint returned HTTP {resp.status_code}")
        body = resp.json()
        expires_in = int(body.get("expires_in", 300))  # conservative default
        self._token = body["access_token"]
        # Refresh at 75 to 80 percent of the lifetime so workers spread out.
        self._refresh_at = time.monotonic() + expires_in * random.uniform(0.75, 0.80)
        log.info("Token acquired for %s, valid %s seconds", self.client_id, expires_in)

    def _usable(self, rejected: Optional[str]) -> bool:
        return (self._token is not None
                and self._token != rejected
                and time.monotonic() < self._refresh_at)

    def get_token(self, rejected: Optional[str] = None) -> str:
        """Return the cached token; only one thread refreshes at a time."""
        if self._usable(rejected):
            return self._token
        with self._lock:
            if not self._usable(rejected):      # another thread may have refreshed
                self._fetch_token()
            return self._token

    def call_api(self, method: str, url: str, **kwargs) -> requests.Response:
        token = self.get_token()
        resp = self._send(method, url, token, **kwargs)
        challenge = resp.headers.get("WWW-Authenticate", "")
        if resp.status_code == 401 and "invalid_token" in challenge:
            token = self.get_token(rejected=token)            # refresh once
            resp = self._send(method, url, token, **kwargs)   # retry once
            if resp.status_code == 401:
                raise_alert(f"Token rejected after refresh for {self.client_id}")
                raise AuthFailure("API still returned 401 after one refresh")
        return resp

    @staticmethod
    def _send(method: str, url: str, token: str, **kwargs) -> requests.Response:
        headers = dict(kwargs.pop("headers", None) or {})
        headers["Authorization"] = f"Bearer {token}"
        kwargs.setdefault("timeout", 30)
        return requests.request(method, url, headers=headers, **kwargs)

The lock covers one process; across pods or serverless instances, use a shared cache with a distributed lock, or a sidecar. For Salesforce or Workday, send grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer with the signed JWT in the assertion parameter.

How do you rotate secrets, certificates and signing keys without downtime?

Rotate without downtime by overlapping credentials. Issue the new credential, register it beside the old one, move the runtime to it, verify real API calls, wait one full token lifetime, then revoke the old credential. The overlap must outlast every cache that could still hold the old value.

  • Issue the new credential with an expiry date and an owner.
  • Register it beside the old one, so both are valid.
  • Deploy runtime configuration that uses it.
  • Verify token issuance and a real API call.
  • Monitor for one token lifetime plus any cache TTL.
  • Revoke the old credential, using OAuth 2.0 token revocation in RFC 7009 where supported.

Rotation without an overlap window is an outage you scheduled yourself. Where a platform allows only one active credential per client, register a second client with identical permissions, cut over, then retire the first; confirm credential limits in the platform documentation.

Client secrets

Store the new secret as a new vault version and point the runtime at it. AWS Secrets Manager supports managed rotation and rotation by a Lambda function that updates both the secret and the target service.

Client certificates

Register the new certificate beside the current one; in Microsoft Entra ID, the x5t#S256 header identifies the signing certificate. Azure Key Vault certificate autorotation works only with partnered CAs, DigiCert and GlobalSign, and by default issues 12-month certificates that renew at 80 percent of lifetime.

Signing keys published through a JWKS

Publish the new key under a new kid while the old key stays listed. Wait at least one consumer cache TTL before signing with it, because MuleSoft’s JWT Validation policy caches keys for 60 minutes by default and keeps the cached set if a refresh fails. Retire the old key after the longest-lived token it signed has expired.

{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "alg": "RS256",
      "kid": "integration-signing-current",
      "n": "{base64url-modulus-of-current-key}",
      "e": "AQAB"
    },
    {
      "kty": "RSA",
      "use": "sig",
      "alg": "RS256",
      "kid": "integration-signing-next",
      "n": "{base64url-modulus-of-next-key}",
      "e": "AQAB"
    }
  ]
}

Refresh tokens

Enable rotation on use where the authorization server supports it. Replace non-expiring refresh tokens on a schedule and whenever staff or vendors with access change. An integration should never hold a credential that outlives the person who created it.

Recommended rotation cadence

These values are recommended practice, not vendor requirements.

Credential type Recommended maximum lifetime Rotation trigger Owner
Client secret 90 days Schedule, staff change, exposure Platform team
Client certificate 12 months Renewal window, key compromise Platform team with PKI owner
JWKS signing key 6 to 12 months Schedule, suspected compromise Issuing service team
Long-lived refresh token 90 days Schedule, staff or vendor change Interface owner

What breaks token handling at scale?

Token handling breaks at scale through timing and sharing faults: clock drift against short assertions, many workers refreshing at once, one integration user shared across interfaces, audience mismatches after adding a resource, tokens leaking into logs, and certificates expiring with no alert. Each has an inexpensive fix.

Clock skew matters because assertions now live for minutes, and Salesforce allows only 3 minutes of skew. Run NTP on every runtime host and alert on drift.

Token stampedes follow deployments and batch starts, when every worker wants a token at once. Single-flight refresh, jitter and a shared cache remove the spike.

One integration user shared by many integrations couples their fate: one key rotation breaks every interface. Give each integration its own client and identity.

Audience restriction binds an access token to the one resource server meant to accept it, so adding a second API often fails with a wrong-audience token. In Microsoft Entra ID, request a separate token per resource.

Redact Authorization headers at the logging layer, because tokens leak through debug logs, exceptions and error payloads copied into tickets. Then alert on days to credential expiry and token endpoint error rate, following our guide to setting alerting thresholds before integrations fail.

How does this change for enterprises, SMEs and startups?

The principles hold at every size; the sequence changes. Enterprises need a central credential inventory and platform-owned rotation, SMEs should move secrets into a vault and convert their riskiest integrations first, and startups should avoid personal identities and document rotation before their first enterprise security review.

An enterprise with 50 or more interfaces needs one inventory of every OAuth client, its credential type, expiry date and owner. Apply sender-constrained tokens to high-risk APIs such as payroll, and have the platform team own rotation.

An SME with one integration developer should first move secrets out of connector fields and scripts into the iPaaS credential store or a cloud vault, then switch the two or three highest-risk integrations to certificate authentication.

A startup should never use a founder’s or engineer’s personal account as an integration identity. Use workload identity federation where the cloud provider offers it, and document rotation before the first enterprise security questionnaire asks.

Which metrics show your integration authentication is healthy?

Four metrics show authentication health: token endpoint error rate, days to the nearest credential expiry, the share of integrations using asymmetric client authentication, and mean time to rotate a compromised credential. Baseline each over 30 days of current data, then set targets that remove expiry surprises and shorten rotation.

Measure token endpoint error rate per client as failed token requests divided by total requests. Pull days to nearest expiry from the credential inventory and alert below 30 days. Count integrations on private_key_jwt, mutual TLS or federation against the total, and baseline time to rotate with a timed drill on one non-critical integration. Review all four monthly with the security team.

Frequently Asked Questions

Is the client credentials grant secure enough for enterprise integrations?

Yes, when the client authenticates with private_key_jwt, mutual TLS or workload identity federation, and tokens are short-lived and audience-restricted. The usual weakness is a long-lived shared secret, not the grant. For high-risk APIs, add sender-constrained tokens so a stolen token cannot be replayed from another host.

What is the difference between private_key_jwt and the JWT bearer grant?

Both use a signed JWT from RFC 7523, for different jobs. With private_key_jwt, the JWT authenticates the client and replaces the client secret in a grant such as client credentials. With the JWT bearer grant, the JWT is the grant itself, usually asserting a specific integration user in the sub claim.

Should integrations use refresh tokens?

Usually not. RFC 6749 says the client credentials grant SHOULD NOT return a refresh token, and Microsoft Entra ID and the Salesforce JWT bearer flow issue none. Where Workday or Infor ION API Gateway offers them, treat each as a long-lived credential with an owner, a replacement schedule and a revocation path.

How often should integration client secrets be rotated?

As recommended practice, rotate client secrets at least every 90 days and immediately after suspected exposure or a change in staff or vendors with access. Confirm each platform’s maximum secret lifetime, rotate more often when a vault automates it, and always use an overlap window.

Do sender-constrained tokens work with iPaaS platforms?

Sometimes. The authorization server, resource server and client runtime must all support mutual TLS certificate binding or DPoP, and support varies across SaaS endpoints and iPaaS connectors. Apply them first to high-risk APIs you control, and use private_key_jwt with short-lived tokens elsewhere.

Start with the inventory: list every OAuth client, its credential type, expiry date and owner, then move the riskiest integrations to certificate-based client authentication. For help with token clients, rotation runbooks and the cutover itself, our custom integration development services cover design through production support.

;