When setting up external applications to access Salesforce data, we have traditionally created Connected Apps manually per environment. With the move to External Client Apps (ECAs), the goal is to manage and deploy these integrations from source without manual setup per environment.
ECAs split what Connected Apps combine into separate metadata components — separating the app definition, OAuth configuration, secrets, and access policies. This separation enables better packaging, security, and governance but requires a different deployment strategy.
This document outlines the proven deployment patterns, key constraints discovered through testing, and the recommended approach for client credentials flow integrations where the run-as user varies per environment.
| Metadata Type | Description | Suffix | Packageable | Source Deployable | Managed By |
|---|---|---|---|---|---|
ExternalClientApplication |
App identity/header | .eca-meta.xml |
Yes | Yes | Developer |
ExtlClntAppOauthSettings |
OAuth scopes, oauthLink | .ecaOauth-meta.xml |
Yes | Yes (but requires valid oauthLink) |
Developer |
ExtlClntAppGlobalOauthSettings |
Consumer key/secret, flow toggles | .ecaGlblOauth-meta.xml |
No | No via source deploy; Yes via Metadata API SOAP (anonymous Apex) | Platform |
ExtlClntAppConfigurablePolicies |
Plugin enable/disable | .ecaPlcy-meta.xml |
No | Yes (after OAuth is enabled) | Org Admin |
ExtlClntAppOauthConfigurablePolicies |
IP relaxation, refresh token, client credentials user | .ecaOauthPlcy-meta.xml |
No | Yes (after OAuth is enabled) | Org Admin |
ExternalClientApplication+ExtlClntAppOauthSettingscan go in an unlocked packageExtlClntAppGlobalOauthSettings(consumer key/secret) is never in source control — auto-generated by the platform when OAuth is enabled- Policies are per-org configuration — deployed via source package, or auto-generated with defaults on package install
The oauthLink in ExtlClntAppOauthSettings references an OAuth Consumer record on the DevHub in the format OrgId:ConsumerRecordId. It is auto-generated when OAuth is enabled via the Setup UI.
Critical constraint discovered through testing:
Never deploy
ExtlClntAppOauthSettingswithout a validoauthLink. Doing so breaks the OAuth plugin on the target org with the error: "Cannot invoke String.split(String) because oauthLink is null". TheoauthLinkmust be retrieved from the platform — it cannot be manually constructed.
All consumer orgs share the same OAuth consumer key/secret from the DevHub. The run-as user for client credentials flow is configured per environment via a source package with aliasfy.
This pattern minimises manual setup: install the package, deploy environment-specific policies, done.
src/integrations/
├── eca-definition/ ← UNLOCKED PACKAGE
│ ├── externalClientApps/
│ │ └── MyApp.eca-meta.xml (distributionState: Packaged)
│ └── extlClntAppOauthSettings/
│ └── MyApp_oauth.ecaOauth-meta.xml (WITH oauthLink from DevHub)
│
└── eca-policies/ ← SOURCE PACKAGE (aliasfy: true) — OPTIONAL
├── prod/
│ └── extlClntAppOauthPolicies/
│ └── MyApp_oauthPlcy.ecaOauthPlcy-meta.xml (client creds ON, prod run-as user)
├── uat/
│ └── extlClntAppOauthPolicies/
│ └── MyApp_oauthPlcy.ecaOauthPlcy-meta.xml (client creds ON, uat run-as user)
└── default/
└── extlClntAppOauthPolicies/
└── MyApp_oauthPlcy.ecaOauthPlcy-meta.xml (client creds OFF — scratch orgs)
The policies source package is optional. When the unlocked package is installed, the platform auto-generates default policies (client credentials disabled, all users self-authorized, standard session level). You only need the policies source package if you require per-environment customization such as:
- Enabling client credentials flow with a different run-as user per org
- Changing IP relaxation or refresh token settings per environment
- Restricting permitted users per org
If the auto-generated defaults are sufficient (e.g., web server flow only), just install the unlocked package — no policies deployment needed.
-
Create the External Client App on the DevHub via the Setup UI:
- Navigate to Setup > External Client Apps > New
- Set the app name, contact email, and description
- Enable OAuth plugin
- Set callback URL, scopes (Api, RefreshToken, etc.)
- Enable Client Credentials flow if needed
- Save
-
Retrieve the ECA and OAuth settings — this captures the platform-generated
oauthLinkandorgScopedExternalApp:sf project retrieve start --metadata "ExternalClientApplication:MyApp" \ --target-org <devhub> sf project retrieve start --metadata "ExtlClntAppOauthSettings:MyApp_oauth" \ --target-org <devhub>
-
Copy the retrieved files into the unlocked package source directory:
ExternalClientApplication→src/integrations/eca-definition/externalClientApps/ExtlClntAppOauthSettings→src/integrations/eca-definition/extlClntAppOauthSettings/
-
Set
distributionStatetoPackagedin the retrieved ECA header (it will beLocalby default after retrieval) -
Commit both files to source control — the
oauthLinkandorgScopedExternalAppare included as-is -
Delete the source-deployed ECA from the DevHub (the package will recreate it on install):
sf project delete source --metadata "ExternalClientApplication:MyApp" \ --target-org <devhub> --no-prompt
# Build all packages (unlocked ECA package + source policies package)
sfp build -v <devhub> --branch <branch>
# Install all packages to the target org
# sfp handles the correct order: unlocked packages first, then source packages
# For source packages with aliasfy, sfp automatically deploys the folder matching the org alias
sfp install -u <target-org>Client credentials flow configuration spans two metadata types:
| Setting | Metadata Type | How to Configure |
|---|---|---|
| Enable Client Credentials Flow (app-level toggle) | ExtlClntAppGlobalOauthSettings |
Pattern 1: one-time via Setup UI on DevHub (carries to all consumer orgs via package). Pattern 2: automated via Apex bootstrap. |
| Execution user (run-as user) | ExtlClntAppOauthConfigurablePolicies |
Source package with aliasfy — clientCredentialsFlowUser field per environment |
| IP relaxation, refresh token, session level | ExtlClntAppOauthConfigurablePolicies |
Source package with aliasfy |
| Permitted users policy | ExtlClntAppOauthConfigurablePolicies |
Source package with aliasfy |
For Pattern 1, the client credentials flow toggle is set once on the DevHub via the Setup UI. All consumer orgs inherit this through the package — no per-org manual step.
For Pattern 2, the Apex bootstrap script creates ExtlClntAppGlobalOauthSettings with the flow toggle — fully automated, no UI step on any org.
Use aliasfy to vary the OAuth policies per environment. The execution user for client credentials is controlled via the permittedUsersPolicyType and permission set assignments on each org.
prod/extlClntAppOauthPolicies/MyApp_oauthPlcy.ecaOauthPlcy-meta.xml:
<?xml version="1.0" encoding="UTF-8"?>
<ExtlClntAppOauthConfigurablePolicies xmlns="http://soap.sforce.com/2006/04/metadata">
<clientCredentialsFlowUser>integration-user@prod.example.com</clientCredentialsFlowUser>
<externalClientApplication>MyApp</externalClientApplication>
<ipRelaxationPolicyType>Enforce</ipRelaxationPolicyType>
<isClientCredentialsFlowEnabled>true</isClientCredentialsFlowEnabled>
<isTokenExchangeFlowEnabled>false</isTokenExchangeFlowEnabled>
<label>MyApp_oauthPlcy</label>
<permittedUsersPolicyType>AllSelfAuthorized</permittedUsersPolicyType>
<refreshTokenPolicyType>SpecificLifetime</refreshTokenPolicyType>
<refreshTokenValidityPeriod>365</refreshTokenValidityPeriod>
<refreshTokenValidityUnit>Days</refreshTokenValidityUnit>
<requiredSessionLevel>STANDARD</requiredSessionLevel>
</ExtlClntAppOauthConfigurablePolicies>Note: When
isClientCredentialsFlowEnabledistrue, theclientCredentialsFlowUserfield is required. This is the run-as user that varies per environment.
default/ (scratch orgs — client credentials disabled, no user needed):
<?xml version="1.0" encoding="UTF-8"?>
<ExtlClntAppOauthConfigurablePolicies xmlns="http://soap.sforce.com/2006/04/metadata">
<externalClientApplication>MyApp</externalClientApplication>
<ipRelaxationPolicyType>Enforce</ipRelaxationPolicyType>
<isClientCredentialsFlowEnabled>false</isClientCredentialsFlowEnabled>
<isTokenExchangeFlowEnabled>false</isTokenExchangeFlowEnabled>
<label>MyApp_oauthPlcy</label>
<permittedUsersPolicyType>AllSelfAuthorized</permittedUsersPolicyType>
<refreshTokenPolicyType>SpecificLifetime</refreshTokenPolicyType>
<refreshTokenValidityPeriod>365</refreshTokenValidityPeriod>
<refreshTokenValidityUnit>Days</refreshTokenValidityUnit>
<requiredSessionLevel>STANDARD</requiredSessionLevel>
</ExtlClntAppOauthConfigurablePolicies>| Concern | How It's Handled |
|---|---|
| App identity + OAuth config | Unlocked package (versioned, repeatable) |
| Consumer key/secret | Auto-managed by platform, shared from DevHub |
| Client credentials flow toggle | One-time on DevHub via Setup UI — carries to all consumer orgs via package |
| Execution user per environment | Aliasfy source package — clientCredentialsFlowUser field per org |
| IP relaxation, refresh token, etc. | Aliasfy source package (different policy per org) |
| Credential rotation | Single change on DevHub, no redeployment needed |
Each org has its own OAuth consumer key/secret. OAuth is bootstrapped automatically via an anonymous Apex post-deployment script — no manual Setup UI clicks required.
src/integrations/
├── eca-definition/ ← SOURCE PACKAGE
│ └── externalClientApps/
│ └── MyApp.eca-meta.xml (distributionState: Local)
│
├── eca-policies/ ← SOURCE PACKAGE (aliasfy) — OPTIONAL
│ ├── <org-alias>/
│ │ └── extlClntAppOauthPolicies/
│ │ └── MyApp_oauthPlcy.ecaOauthPlcy-meta.xml (client creds ON, org-specific user)
│ └── default/
│ └── extlClntAppOauthPolicies/
│ └── MyApp_oauthPlcy.ecaOauthPlcy-meta.xml (client creds OFF — scratch orgs)
│
└── scripts/
├── bootstrap-eca-oauth.sh ← postDeploymentScript (shell wrapper)
└── bootstrap-eca-oauth.apex ← anonymous Apex (creates OAuth consumer)
The ExternalClientApplication header is deployed as a source package. A postDeploymentScript runs anonymous Apex that calls the Metadata API SOAP endpoint (upsertMetadata) directly on the target org to create:
RemoteSiteSetting— required for the SOAP callout to the org's own Metadata APIExtlClntAppOauthSettings— OAuth scopes and settingsExtlClntAppGlobalOauthSettings— the OAuth consumer (callback URL, security settings)ExtlClntAppConfigurablePolicies— enables the OAuth plugin
This creates a fully functional OAuth consumer with its own unique key/secret on each org — no manual UI step needed.
sfdx-project.json:
{
"path": "src/integrations/eca-definition",
"package": "eca-definition",
"versionNumber": "1.0.0.NEXT",
"type": "source",
"postDeploymentScript": "scripts/bootstrap-eca-oauth.sh"
},
{
"path": "src/integrations/eca-policies",
"package": "eca-policies",
"versionNumber": "1.0.0.NEXT",
"type": "source",
"aliasfy": true
}The bootstrap shell script (bootstrap-eca-oauth.sh):
Based on the approach from lightweight-soap-util, the script inlines anonymous Apex via heredoc and runs it with sf apex run. The Apex makes raw SOAP callouts to the Metadata API upsertMetadata endpoint — no managed package dependency required. It dynamically resolves the org's domain URL (URL.getOrgDomainUrl()) and creates all OAuth components in a single execution. Configuration (app name, scopes) is set at the top of the shell script and injected via sed placeholder replacement.
- Commit the
ExternalClientApplicationheader and the bootstrap scripts to source control sfp buildbuilds the source packagesfp installdeploys the header and runs the post-deployment Apex script- Each org gets its own OAuth consumer with unique credentials
- Aliasfy policies deploy per environment (client credentials user, IP relaxation, etc.)
- Subsequent deploys of the header do not overwrite OAuth settings already on the org
- Strict credential isolation required between environments (regulated industries)
- Each org connects to a different instance of the external system
- Organisation policy prohibits shared credentials across orgs
- Sandbox integrations with non-production systems that cannot protect production credentials
| Aspect | Pattern 1 (Shared) | Pattern 2 (Independent) |
|---|---|---|
| Package type | Unlocked (org-dependent) | Source |
| Credentials | Shared from DevHub | Unique per org (auto-generated) |
| OAuth setup | Once on DevHub via UI, retrieve + package | Automated via anonymous Apex post-deployment script |
oauthLink |
Required (retrieved from DevHub) | Not used (Apex creates OAuth consumer directly) |
distributionState |
Packaged |
Local |
| Client credentials user | Per-env via aliasfy policies | Per-env via aliasfy policies |
| Credential rotation | Single point (DevHub) | Per org |
| Manual effort per org | Zero (install package + deploy policies) | Zero (deploy header + Apex bootstrap + deploy policies) |
| Sandbox refresh | ECA survives (verified) | ECA lost — must redeploy + re-bootstrap |
| Scratch org pools | Works via sfp prepare (package as dependency) |
Works via sfp prepare (source deploy + Apex bootstrap) |
| Single point of failure | Yes (DevHub OAuth consumer) | No |
-
Never deploy
ExtlClntAppOauthSettingswithout a validoauthLink— this breaks the OAuth plugin and cannot be recovered without deleting the metadata -
Always retrieve OAuth settings from the platform — the
oauthLinkis a reference to an auto-generated OAuth Consumer record and cannot be manually constructed -
ExtlClntAppGlobalOauthSettings(consumer key/secret) never goes in source control — it is auto-generated when OAuth is enabled via the UI and is managed entirely by the platform -
Install the package on the DevHub first — consumer orgs reference the DevHub's OAuth consumer via
oauthLink, so the package must exist on the DevHub before installing elsewhere -
Policies are auto-generated on package install — you only need to deploy custom policies if the defaults need to change (e.g., enabling client credentials flow, changing IP relaxation)
-
Redeploying the ECA header does not overwrite OAuth settings — safe for repeated deployments in both patterns
-
Non-org-dependent unlocked packages require the scratch org definition to point to the correct DevHub — if the
project-scratch-def.jsonreferences a different org shape, package version creation fails with SH-0001 errors -
ExtlClntAppGlobalOauthSettingscannot be deployed to scratch orgs viasf project deploy— the platform returns: "Ephemeral orgs such as scratch orgs can't be used to deploy packaged ECAs." However, it can be created via the Metadata API SOAP endpoint (upsertMetadata) using anonymous Apex running on the org itself — this is how the Pattern 2 bootstrap script works. -
ExtlClntAppOauthConfigurablePolicieswith client credentials flow fails on scratch orgs if the execution user doesn't exist — use a scratch-org-friendly default policy that does not enable client credentials flow.
A common first approach is to retrieve all ECA metadata from one org and deploy it to another. This fails because:
| Metadata | Error When Deploying to Another Org |
|---|---|
ExtlClntAppGlobalOauthSettings |
"Ephemeral orgs such as scratch orgs can't be used to deploy packaged ECAs" (scratch orgs) or consumer key conflict (sandboxes) |
ExtlClntAppOauthSettings |
"There was a problem with the OAuth link. The app either isn't available on this instance" — the oauthLink references an OAuth Consumer record ID that only exists on the source org |
ExtlClntAppOauthConfigurablePolicies |
"Enter a valid execution user for the OAuth client credentials flow" — the run-as user doesn't exist on the target org |
The oauthLink is the core problem: it contains an org-specific OAuth Consumer record reference (OrgId:ConsumerRecordId). This ID is unique to the org where OAuth was enabled and cannot be transferred to another org via source deploy.
The solution: Use an unlocked package. When packaged, the platform handles the oauthLink resolution across orgs. Consumer orgs reference the DevHub's OAuth consumer through the package mechanism.
Both patterns support development workflows with scratch org pools (sfp prepare) and sandbox pools. The key consideration is whether lower environments share production credentials or use isolated test credentials.
| Action | Works? |
|---|---|
Install unlocked package (ECA + OAuth settings with oauthLink) |
Yes |
Source deploy ExternalClientApplication header only |
Yes |
Deploy ExtlClntAppGlobalOauthSettings |
No — "Ephemeral orgs can't be used to deploy packaged ECAs" |
Deploy ExtlClntAppOauthSettings from another org |
No — oauthLink is org-specific |
Deploy ExtlClntAppOauthConfigurablePolicies with client credentials user |
No — execution user doesn't exist |
Deploy ExtlClntAppOauthConfigurablePolicies without client credentials |
Yes |
Deploy ExtlClntAppConfigurablePolicies |
Yes |
| Enable OAuth via Setup UI (after deploying header only) | Yes |
Create two ECAs on the DevHub — one for production and one for non-production environments. Each is a separate unlocked package with its own OAuth consumer, callback URL, and credentials. This ensures complete isolation between production and development.
When to use: The external system has separate test and production instances, you need credential isolation between development and production, or organizational policy requires that non-production environments cannot access production external systems.
Note: Users can create both ECAs on the same DevHub. The DevHub serves as the central management point for all ECA packages. The "non-production" app is not a lesser version — it is a fully functional ECA with its own OAuth consumer, configured with callback URLs pointing to the test/sandbox instance of the external system.
Repository structure:
src/integrations/
├── eca-definition/ ← UNLOCKED PACKAGE (production)
│ ├── externalClientApps/
│ │ └── MyApp.eca-meta.xml (oauthLink → prod consumer)
│ └── extlClntAppOauthSettings/
│ └── MyApp_oauth.ecaOauth-meta.xml
│
├── eca-definition-nonprod/ ← UNLOCKED PACKAGE (non-production)
│ ├── externalClientApps/
│ │ └── MyApp_NonProd.eca-meta.xml (oauthLink → non-prod consumer)
│ └── extlClntAppOauthSettings/
│ └── MyApp_NonProd_oauth.ecaOauth-meta.xml
│
└── eca-policies/ ← SOURCE PACKAGE (aliasfy) — OPTIONAL
├── prod/
│ └── ... (client credentials ON, prod user)
├── uat/
│ └── ... (client credentials ON, uat user)
└── default/
└── ... (client credentials OFF for scratch)
Setup on DevHub:
- Create
MyAppECA on DevHub via Setup UI → enable OAuth with production callback URL and scopes → retrieve → package aseca-definition - Create
MyApp_NonProdECA on DevHub via Setup UI → enable OAuth with non-production callback URL (pointing to test/sandbox instance of external system) → retrieve → package aseca-definition-nonprod
sfdx-project.json — dependencies for lower environments:
{
"path": "src/my-app",
"package": "my-app",
"versionNumber": "1.0.0.NEXT",
"dependencies": [
{
"package": "eca-definition-nonprod",
"versionNumber": "1.0.0.LATEST"
}
]
}Release configuration — production uses the production package:
The release config for production environments includes eca-definition (not eca-definition-nonprod). The release config for lower environments includes eca-definition-nonprod.
sfp prepare flow (scratch org pools):
- Scratch org is created from pool
eca-definition-nonprodpackage is installed — points to non-production OAuth consumer with non-production callback URL- Policies source package deploys
default/folder - Developer works against the test instance of the external system — no risk to production
Benefits:
- Complete credential isolation between production and development
- Non-production callback URL points to the test/sandbox instance of the external system
- Scratch org developers cannot accidentally interact with production
- Both packages are managed on the same DevHub — single place for credential management
The ECA header is deployed as a source package with a postDeploymentScript that runs anonymous Apex to bootstrap OAuth.
sfp prepare flow:
- Scratch org is created from pool
- Source package deploys
ExternalClientApplicationheader - Post-deployment script runs anonymous Apex — creates OAuth consumer, settings, and policies via Metadata API SOAP
- Aliasfy policies deploy
default/folder (client credentials OFF) - Each scratch org gets its own independent OAuth consumer and credentials — fully automated
Sandboxes are non-ephemeral and support all ECA metadata types.
Pattern 1: Install the appropriate unlocked package (production or test) + deploy policies with the correct environment alias (client credentials user for that sandbox).
Pattern 2: Source deploy ECA header + Apex bootstrap creates OAuth automatically + deploy aliasfy policies.
Verified behavior:
- Local ECAs (
distributionState: Local, created via source deploy) on prod → verified: NOT replicated to sandboxes on refresh. Tested by deployingAuthorizedTraderandORDE_ERP2as Local ECAs to prod, then refreshing sandboxdemo55— neither appeared. - Packaged ECAs (installed via unlocked package) on prod → verified: ARE replicated to sandboxes on refresh.
ORDE_Middleware,ORDE_ERP, anderp3(all installed via package) appeared ondemo55after refresh. - Scratch orgs always start clean — no ECAs from the DevHub/prod are inherited (verified with fresh scratch org creation).
For Pattern 1, if the unlocked package is installed on prod, sandbox refreshes will include the ECA automatically. No manual steps needed post-refresh — only deploy the aliasfy policies if per-environment customization is required.
For Pattern 2, every sandbox refresh requires: redeploy ECA header + run bootstrap script + deploy policies. With the anonymous Apex bootstrap script, this is fully automated — no manual UI step needed post-refresh.
The clientCredentialsFlowUser field in ExtlClntAppOauthConfigurablePolicies requires a valid username on the target org. Scratch org usernames are auto-generated (e.g., test-aowz1ztfg0ei@example.com) and cannot be predicted ahead of time.
sfp's replacement system uses org aliases to select environment-specific values but does not provide a built-in variable for the target org's username.
Recommended approach: Keep isClientCredentialsFlowEnabled: false in the default/ aliasfy folder (used for scratch orgs). Developers do not need client credentials flow on scratch orgs — they are testing application logic, not the integration authentication flow. Client credentials testing is done on sandboxes and higher environments where usernames are known and stable.
Across all org types (scratch, sandbox, production), the deployment order is:
- Deploy ECA — via package install (Pattern 1) or source deploy of header only (Pattern 2)
- Enable OAuth — automatic via package install (Pattern 1) or via anonymous Apex bootstrap script (Pattern 2)
- Deploy policies — via aliasfy source package. Policies cannot be deployed before OAuth is enabled — the platform returns: "The OAuth settings are missing for this external client app."
This order is enforced by the platform and cannot be bypassed.
| Environment | Pattern 1 (Packaged) | Pattern 2 (Source + Apex Bootstrap) |
|---|---|---|
| Production | One-time: create ECA on DevHub via UI, retrieve oauthLink, package |
Zero — deploy header + Apex bootstrap creates OAuth |
| Sandboxes | Zero — package install + deploy policies | Zero — deploy header + Apex bootstrap + deploy policies |
| Scratch orgs | Zero — package install via sfp prepare + deploy policies |
Zero — deploy header + Apex bootstrap + deploy policies |
| Post sandbox refresh | Zero — deploy policies only | Zero — redeploy header + Apex bootstrap + deploy policies |
For the use case of an external application accessing Salesforce data via client credentials flow with a different run-as user per environment:
Both patterns are now fully automatable with zero manual steps on consumer orgs. Choose based on your credential management requirements:
Pattern 1 (Packaged — shared credentials) when:
- You want centralized credential management on the DevHub
- All orgs should share the same OAuth consumer (or separate prod/non-prod consumers)
- Sandbox refreshes should preserve the ECA automatically
- One-time manual setup is acceptable (create ECA + enable OAuth on DevHub, then retrieve and package)
Pattern 2 (Source + Apex bootstrap — independent credentials) when:
- Each org must have its own unique OAuth consumer key/secret
- Strict credential isolation is required (regulated industries)
- You want fully automated deployment with no manual UI steps on any org including the DevHub
- Each org connects to a different instance of the external system
Both patterns support:
- Environment-specific client credentials configuration via aliasfy (run-as user per org)
- Automated scratch org pool creation via
sfp prepare - Version-controlled, repeatable deployments via
sfp buildandsfp install - Client credentials disabled on scratch orgs (usernames are auto-generated and unpredictable)