← Back to notes

Developers · Tutorial

Connect Salesforce to Salesforce using JWT

A working Salesforce-to-Salesforce JWT bearer flow using an External Client App, a certificate, custom metadata and Apex you can paste into a real org.

Connect Salesforce to Salesforce using JWT

This tutorial documents a working org-to-org JWT bearer flow between two Salesforce environments. One org signs the JWT with a certificate. The second org validates that signature through an External Client App and returns an access token that Apex can use for callouts.

Outcome: you end up with an Apex method that returns an access token for a second Salesforce org without storing a password in code.

1. Prepare the target org user

The target org needs a real user that the JWT will impersonate. In the demo that user was a System Administrator inside Salesforce 2. In a production org I would prefer a dedicated integration user or at least a reduced profile plus permission sets.

User detail page for Admin Salesforce2 in the target org
Target org user detail. The JWT subject must match this username and the profile must be authorized for the app.
  • The username is the value you later send in jwt.setSub(...).
  • The profile or permission set must be allowed by the External Client App policy.
  • If you change the username later, the JWT flow stops working until you update the metadata in the source org.

2. Create the External Client App in Salesforce 2

The most important part of the target org configuration is the External Client App. This is where Salesforce decides who can use the app, which flows are enabled and which certificate is trusted.

External Client App configuration in Salesforce 2
External Client App setup in Salesforce 2, including OAuth scopes, JWT Bearer Flow, certificate upload and app security controls.
Setting Value used in the demo Why it matters
Permitted Users Admin approved users are pre-authorized Keeps the app locked down to explicitly authorized users, profiles or permission sets.
Selected Profiles System Administrator The JWT subject must belong to a profile or permission set allowed here.
Callback URL http://localhost:1717/OauthRedirect JWT does not use an interactive redirect, but Salesforce still expects a valid callback URL in the app settings UI.
OAuth Scopes api, web, full, refresh_token These were enough for the demo. Keep only the scopes your integration genuinely needs.
Flow Enablement Enable JWT Bearer Flow This is the switch that makes the certificate-based token exchange possible.
Certificate Upload Public certificate from Salesforce 1 The target org validates the signature against this certificate.
Refresh Token Policy Expire refresh token after specific time / 365 Day(s) This is how the demo org was configured. JWT flows can often stay simpler because they mint tokens on demand.
IP Relaxation Enforce IP restrictions Use the level of network control your environment requires.

After you save the app, open the consumer key screen and copy the Consumer Key. That is the value stored later as Client_Id__c in custom metadata.

3. Create the certificate in Salesforce 1

In the source org, go to Certificate and Key Management and create a self-signed certificate. The demo used JWT_CERTIFICATE. The name matters because Apex references it directly when building the signed JWS.

  • Create the certificate in the org that will execute the Apex callout.
  • Upload that public certificate into the JWT bearer flow configuration of the target org app.
  • If you rotate the certificate later, update both sides: the source org certificate reference and the target org trusted certificate.

4. Store the consumer key and JWT username in custom metadata

I kept the runtime values outside the class in a custom metadata record so the code stays deployable and the configuration stays editable. In the demo the type was JWT_Setting__mdt and the record label was ConfigJWT.

JWT custom metadata detail page
Custom metadata stores the consumer key and the JWT username outside the class body. The username remains visible for mapping clarity; the consumer key is intentionally redacted.
  • Client_Id__c: the External Client App consumer key from Salesforce 2.
  • UsernameJWT__c: the integration username from Salesforce 2.
  • ConfigJWT: a readable label so the Apex query can load one known configuration record.

5. Validate the flow from Salesforce 1

Once the metadata record, certificate and External Client App are in place, the last visual checkpoint is the execution result in Salesforce 1. The important thing here is not the editor tab itself, but the log entry proving the token exchange succeeded.

Execute Anonymous
System.debug(
    'Access Token: ' + GetAccessTokenSalesforce2.getAccessToken()
);
Execution log showing Access Token SESSION_ID_REMOVED
The flow worked. Salesforce returned the token and then masked it in the debug log as SESSION_ID_REMOVED.

If the debug log shows SESSION_ID_REMOVED, that is usually a good sign. Salesforce returned the token, but the platform redacted it in the log output for security reasons.

6. Reusable Apex snippets

The screenshots above document the setup. The code below is the copy-paste appendix I would keep for the next project.

JWTServiceWrapper.cls

This wrapper creates the JWT, derives the correct audience from the target token URL and exchanges it for an access token. I kept the test injection points because they make the class easy to fake in unit tests.

JWTServiceWrapper.cls
public virtual class JWTServiceWrapper {
    private static final String SANDBOX_AUDIENCE = 'https://test.salesforce.com';
    private static final String PRODUCTION_AUDIENCE = 'https://login.salesforce.com';

    @TestVisible
    private String testClientId;
    @TestVisible
    private String testUsernameJWT;

    public void setTestValues(String clientId, String username) {
        testClientId = clientId;
        testUsernameJWT = username;
    }

    public virtual String getTestClientId() {
        return testClientId;
    }

    public virtual String getTestUsernameJWT() {
        return testUsernameJWT;
    }

    public virtual String generateAccessToken(
        String clientId,
        String username,
        String tokenUrl,
        String certificateName
    ) {
        Auth.JWT jwt = new Auth.JWT();
        jwt.setSub(username);
        jwt.setIss(clientId);
        jwt.setAud(getAudienceUrl(tokenUrl));

        Auth.JWS jws = new Auth.JWS(jwt, certificateName);
        Auth.JWTBearerTokenExchange bearer = new Auth.JWTBearerTokenExchange(tokenUrl, jws);
        return bearer.getAccessToken();
    }

    @TestVisible
    private String getAudienceUrl(String tokenUrl) {
        String normalizedUrl = tokenUrl.toLowerCase();
        if (
            normalizedUrl.contains('test.salesforce.com') ||
            normalizedUrl.contains('.sandbox.my.salesforce.com')
        ) {
            return SANDBOX_AUDIENCE;
        }

        return PRODUCTION_AUDIENCE;
    }
}
Audience note: the original quick demo inferred sandbox vs production from the caller org. For a reusable snippet, it is safer to infer the audience from the target token URL instead.

GetAccessTokenSalesforce2.cls

This class is the public entry point. Replace the token URL with the target org domain and keep the certificate name in sync with the one created in Salesforce 1.

GetAccessTokenSalesforce2.cls
public with sharing class GetAccessTokenSalesforce2 {
    private static final String TOKEN_URL =
        'https://your-target-org.my.salesforce.com/services/oauth2/token';
    private static final String CERTIFICATE_NAME = 'JWT_CERTIFICATE';

    @TestVisible
    private static JWTServiceWrapper jwtService = new JWTServiceWrapper();

    public static void setJWTService(JWTServiceWrapper mock) {
        jwtService = mock;
    }

    public static String getAccessToken() {
        String clientId;
        String usernameJWT;

        if (Test.isRunningTest()) {
            clientId = jwtService.getTestClientId();
            usernameJWT = jwtService.getTestUsernameJWT();
        } else {
            JWT_Setting__mdt settings = [
                SELECT Client_Id__c, UsernameJWT__c
                FROM JWT_Setting__mdt
                WHERE MasterLabel = 'ConfigJWT'
                LIMIT 1
            ];

            clientId = settings.Client_Id__c;
            usernameJWT = settings.UsernameJWT__c;
        }

        return jwtService.generateAccessToken(
            clientId,
            usernameJWT,
            TOKEN_URL,
            CERTIFICATE_NAME
        );
    }
}

Execute Anonymous

This is enough to test the integration after the two classes and the metadata record are in place:

Execute Anonymous
System.debug(
    'Access Token: ' + GetAccessTokenSalesforce2.getAccessToken()
);

Common mistakes

  • Using the wrong sub username. It must exist in the target org and be authorized for the app.
  • Uploading the wrong certificate or forgetting to enable JWT Bearer Flow in the External Client App.
  • Hardcoding the consumer key in Apex instead of storing it in deployable configuration.
  • Pointing TOKEN_URL to the wrong org domain.
  • Choosing the wrong audience. Sandboxes authenticate against https://test.salesforce.com; production and Developer Edition use https://login.salesforce.com.
  • Assuming the debug log will print the raw token. Salesforce deliberately redacts it.

Minimal checklist I would copy into a new project

  • Create an integration user in the target org.
  • Create an External Client App and pre-authorize that user or profile.
  • Enable JWT Bearer Flow and upload the source org certificate.
  • Create JWT_CERTIFICATE in the source org.
  • Create JWT_Setting__mdt with Client_Id__c and UsernameJWT__c.
  • Paste the snippets from the appendix, update TOKEN_URL, then run Execute Anonymous.