With the recent focus on security and especially API security I’ve spent a bit of time looking into how to further harden the security of orgs. One of the key areas to harden are the “apps” that API access is performed in the context of. In Salesforce this has traditionally been Connected Apps but these should really be consider legacy now. The “new kid of the block” is External Client Apps (ECA) – but they are not really new. They’ve been on the platform for a while but has really taken center stage with the most recent releases.
One of the things that ECA’s bring is a new architecture that allows for a host of things including much better ISV and packaging support. The architecture is also different and it allows Salesforce to bring better API support. One of the things that Salesforce engineering has been busy adding over the last few releases is a REST API for ECA’s. For the purpose of this post I’ll dive into the key and secret rotation capabilities afforded by this API.
For the purpose of these ECA’s we’re talking OAuth so the way to authenticate to Salesforce is with a client_id and a client_secret. Previously you’ve only been able to have a single set of these – now you can have two sets active at the same time which allows for key and secret rotation. Very nice.
The way this works is by having the main set as well as a staged set. The staged set may have a status of active or rotated. If active the set can be used like the main set of credentials. If rotated the set is automatically deleted after 30 days (this is not explicitly mentioned in the documentation but I found this value in internal documentation).
Step by step
Let me show you step by step how to do this… I’m gonna show this with working API requests using cURL. The steps also use jq to better format the JSON output and extract key pieces of info. Before I started on the steps below I configured an ECA in Salesforce Setup, configured it for the client_credentials flow and noted down the client_id and client_secret (this could be done with API’s as well).
For these examples it’s also important to enable obtaining secrets for ECA’s via the REST API. In Setup go to Apps/External Client Apps and ensure Allow access to External Client App consumer secrets via REST API is enabled.
Please note: I’ve only tested this below API statements with Winter 26 (API version 65.0) as this is where ECA key and secret rotation is slated to go GA (safe habour!)
# define params
export SF_APIVERSION=v65.0
export SF_MYDOMAIN=foo-xx1234xx.my.salesforce.com
export SF_CLIENT_ID=foo
export SF_CLIENT_SECRET=bar
# get an access token and save it in a param
export SF_ACCESS_TOKEN=`curl --silent -X POST -d "grant_type=client_credentials&client_id=$SF_CLIENT_ID&client_secret=$SF_CLIENT_SECRET" https://$SF_MYDOMAIN/services/oauth2/token | tee /dev/tty | jq -r .access_token`
{"access_token":"00DWt0...6Jz","signature":"mkLGHErOBL4QR77B7qs3N1w83r3agqE18OKIKpKHQ3g=","scope":"api","instance_url":"https://foo-xx1234xx.my.salesforce.com","id":"https://login.salesforce.com/id/00DWt00000B2V4bMAF/005Wt000004d0fNIAQ","token_type":"Bearer","issued_at":"1758701441120"}
# test the access token
curl --silent -H "Authorization: Bearer $SF_ACCESS_TOKEN" https://$SF_MYDOMAIN/services/oauth2/userinfo | jq -r .name
Mikkel Flindt Heisterberg
# list the apps we have and get the app identifier from the
# first app. Change as appropriate. The identifier is the ID
# of the ECA from Setup.
SF_APP_ID=`curl --silent -H "Authorization: Bearer $SF_ACCESS_TOKEN" https://$SF_MYDOMAIN/services/data/$SF_APIVERSION/apps/oauth/usage | tee /dev/tty | jq -r ".apps[0].identifier"`
{"apps":[{"accessTokenFormat":"opaque","availableActions":"disable, enable","description":null,"developerName":"ECA_Key_Secret_Rotation_Blog_Post","identifier":"0xIWt0000003lOz","isFromPackage":false,"usageDetailsUrl":"/services/data/v65.0/apps/oauth/usage/0xIWt0000003lOz/users"},{"accessTokenFormat":"opaque","availableActions":"disable, enable","description":null,"developerName":"Credentials_API_Poc","identifier":"0xIWt0000003kzB","isFromPackage":false,"usageDetailsUrl":"/services/data/v65.0/apps/oauth/usage/0xIWt0000003kzB/users"}],"currentPageUrl":"/services/data/v65.0/apps/oauth/usage?page=0&pageSize=100","nextPageUrl":"/services/data/v65.0/apps/oauth/usage?page=1&pageSize=100"}
# now we have the app id we can inspect the main set of credentials. Notice how the secret is not shown as it's specific to a consumer. Grab the consumer id and ask again
curl --silent -H "Authorization: Bearer $SF_ACCESS_TOKEN" https://$SF_MYDOMAIN/services/data/$SF_APIVERSION/apps/oauth/credentials/$SF_APP_ID | jq
{"consumers":[{"id":"888Wt000000O8CXIA0","key":"3MV...y1F.","name":"ECA Key Secret Rotation Blog Post","stagedCredentialsUrl":"/services/data/v65.0/apps/oauth/credentials/0xIWt0000003lOzMAI/888Wt000000O8CXIA0/staged","url":"/services/data/v65.0/apps/oauth/credentials/0xIWt0000003lOzMAI/888Wt000000O8CXIA0"}],"currentPageUrl":"/services/data/v65.0/apps/oauth/credentials/0xIWt0000003lOzMAI"}
# ask again and ask for secret as well
curl --silent -H "Authorization: Bearer $SF_ACCESS_TOKEN" https://$SF_MYDOMAIN/services/data/$SF_APIVERSION/apps/oauth/credentials/$SF_APP_ID/888Wt000000O8CXIA0?part=keyandsecret
{"id":"888Wt000000O8CXIA0","key":"3MV...y1F.","name":"ECA Key Secret Rotation Blog Post","secret":"BCD...D6E","stagedCredentialsUrl":"/services/data/v65.0/apps/oauth/credentials/0xIWt0000003lOzMAI/888Wt000000O8CXIA0/staged","url":"/services/data/v65.0/apps/oauth/credentials/0xIWt0000003lOzMAI/888Wt000000O8CXIA0?part=keyandsecret"}
# now ask for any staged credentials
curl --silent -H "Authorization: Bearer $SF_ACCESS_TOKEN" https://$SF_MYDOMAIN/services/data/$SF_APIVERSION/apps/oauth/credentials/$SF_APP_ID/888Wt000000Nn4rIAC/staged | jq
{
"stagedCredentials": []
}
# as you can see there are none - let's create a set and save
# the output and then extract the key and secret.
curl --silent -X POST -H "Authorization: Bearer $SF_ACCESS_TOKEN" https://$SF_MYDOMAIN/services/data/$SF_APIVERSION/apps/oauth/credentials/$SF_APP_ID/888Wt000000Nn4rIAC/staged | tee new_credentials.json | jq
{
"stagedCredentials": [
{
"createdBy": "foo.xx1234xx@salesforce.com",
"createdDate": "2025-09-24T08:26:11.000Z",
"expirationDate": "2025-10-24T08:26:11.000Z",
"id": "0ugWt00000000w5IAA",
"key": "3MV...g2J",
"secret": "E1F...435",
"state": "active",
"url": "/services/data/v65.0/apps/oauth/credentials/0xIWt0000003lOzMAI/888Wt000000Nn4rIAC/staged/0ugWt00000000w5"
}
],
"url": "/services/data/v65.0/apps/oauth/credentials/0xIWt0000003lOzMAI/888Wt000000Nn4rIAC/staged"
}
export SF_CLIENT_ID=`cat new_credentials.json | jq -r ".stagedCredentials[].key"`
export SF_CLIENT_SECRET=`cat new_credentials.json | jq -r ".stagedCredentials[].secret"`
Wow that was a lot! But now we have two sets of active credentials for this consumer. As you can see the status of the staged credentials we just created is active and the consumer (you!?) should now make plans to switch to these credentials. We can list the staged credentials and we can delete them if we wanted to, by running a HTTP DELETE towards the credential url (curl -X DELETE -H "Authorization: Bearer $SF_ACCESS_TOKEN" https://$SF_MYDOMAIN/services/data/v65.0/apps/oauth/credentials/0xIWt0000003lOzMAI/888Wt000000Nn4rIAC/staged/0ugWt00000000w5). Deleting them would of course make them invalid.
The next step is rotating the credentials. This essentially replaces the main set of credentials with the staged set marking the staged set as rotated and hence marked for deletion (as a staged set).
# rotate the staged credentials to be the main set
curl --silent -X PATCH -H "Authorization: Bearer $SF_ACCESS_TOKEN" -H "Content-Type: application/json" -d '{ "command": "rotate" }' https://$SF_MYDOMAIN/services/data/$SF_APIVERSION/apps/oauth/credentials/$SF_APP_ID/888Wt000000Nn4rIAC/staged/0ugWt00000000w5IAA | jq
{
"createdBy": "storm.ee4025cd74c549@salesforce.com",
"createdDate": "2025-09-24T08:31:05.000Z",
"expirationDate": "2025-10-24T08:31:05.000Z",
"id": "0ugWt00000000xhIAA",
"key": "3MV...Xy3",
"secret": "054...51D",
"state": "rotated",
"url": "/services/data/v65.0/apps/oauth/credentials/0xIWt0000003lOzMAI/888Wt000000Nn4rIAC/staged/0ugWt00000000xhIAA"
}
As you can see the staged credentials now has the status rotated. If you repeat the steps to get the main credentials you’ll see that the main set and the rotated set has the same key and secret. If I attempt to use the prior set of credentials you’ll get an error like the one below.
{"error":"invalid_client_id","error_description":"client identifier invalid"}
You can now delete the staged set of credentials explicitly or let the platform delete them after a while. The act of creating a new set of staged credentials like we did above also deletes the staged rotated set before adding the new set.
YMMV.
Reference