diff --git a/packages/nodes-base/credentials/SalesforceJwtApi.credentials.ts b/packages/nodes-base/credentials/SalesforceJwtApi.credentials.ts
index a2262c45d48..d97ef162752 100644
--- a/packages/nodes-base/credentials/SalesforceJwtApi.credentials.ts
+++ b/packages/nodes-base/credentials/SalesforceJwtApi.credentials.ts
@@ -2,6 +2,7 @@ import type { AxiosRequestConfig } from 'axios';
import axios from 'axios';
import jwt from 'jsonwebtoken';
import moment from 'moment-timezone';
+
import { formatPrivateKey } from '@utils/utilities';
import type {
ICredentialDataDecryptedObject,
@@ -63,6 +64,15 @@ export class SalesforceJwtApi implements ICredentialType {
description:
'Use the multiline editor. Make sure it is in standard PEM key format:
-----BEGIN PRIVATE KEY-----
KEY DATA GOES HERE
-----END PRIVATE KEY-----',
},
+ {
+ displayName: 'My Domain URL',
+ name: 'myDomainUrl',
+ type: 'string',
+ default: '',
+ placeholder: 'https://mycompany.my.salesforce.com',
+ description:
+ "Your org's My Domain URL (e.g. https://mycompany.my.salesforce.com). Required for Spring '26 and later orgs; leave blank to keep the default audience used by earlier orgs.",
+ },
];
async authenticate(
@@ -70,10 +80,7 @@ export class SalesforceJwtApi implements ICredentialType {
requestOptions: IHttpRequestOptions,
): Promise {
const now = moment().unix();
- const authUrl =
- credentials.environment === 'sandbox'
- ? 'https://test.salesforce.com'
- : 'https://login.salesforce.com';
+ const authUrl = resolveAuthUrl(credentials);
const privateKey = formatPrivateKey(credentials.privateKey as string);
const signature = jwt.sign(
{
@@ -118,9 +125,19 @@ export class SalesforceJwtApi implements ICredentialType {
test: ICredentialTestRequest = {
request: {
baseURL:
- '={{$credentials?.environment === "sandbox" ? "https://test.salesforce.com" : "https://login.salesforce.com"}}',
+ '={{$credentials?.myDomainUrl ? $credentials.myDomainUrl.replace(/\\/$/, "") : ($credentials?.environment === "sandbox" ? "https://test.salesforce.com" : "https://login.salesforce.com")}}',
url: '/services/oauth2/userinfo',
method: 'GET',
},
};
}
+
+export function resolveAuthUrl(credentials: ICredentialDataDecryptedObject): string {
+ const myDomainUrl = ((credentials.myDomainUrl as string | undefined) ?? '').replace(/\/$/, '');
+ if (myDomainUrl) {
+ return myDomainUrl;
+ }
+ return credentials.environment === 'sandbox'
+ ? 'https://test.salesforce.com'
+ : 'https://login.salesforce.com';
+}
diff --git a/packages/nodes-base/credentials/test/SalesforceJwtApi.credentials.test.ts b/packages/nodes-base/credentials/test/SalesforceJwtApi.credentials.test.ts
new file mode 100644
index 00000000000..f1201c69067
--- /dev/null
+++ b/packages/nodes-base/credentials/test/SalesforceJwtApi.credentials.test.ts
@@ -0,0 +1,211 @@
+import axios from 'axios';
+import jwt from 'jsonwebtoken';
+import type { IHttpRequestOptions } from 'n8n-workflow';
+
+import { SalesforceJwtApi, resolveAuthUrl } from '../SalesforceJwtApi.credentials';
+
+jest.mock('axios');
+jest.mock('jsonwebtoken', () => ({
+ sign: jest.fn(),
+}));
+jest.mock('@utils/utilities', () => ({
+ formatPrivateKey: (key: string) => key,
+}));
+
+describe('SalesforceJwtApi Credential', () => {
+ const credential = new SalesforceJwtApi();
+ const mockedAxios = axios as unknown as jest.Mock;
+ const mockedSign = jwt.sign as unknown as jest.Mock;
+
+ const baseCredentials = {
+ clientId: 'connected-app-client-id',
+ username: 'user@example.com',
+ privateKey: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----',
+ };
+
+ const requestOptions: IHttpRequestOptions = {
+ headers: {},
+ method: 'GET',
+ url: 'https://login.salesforce.com/services/data/v59.0',
+ };
+
+ beforeEach(() => {
+ mockedAxios.mockReset();
+ mockedAxios.mockResolvedValue({ data: { access_token: 'abc123' } });
+ mockedSign.mockReset();
+ mockedSign.mockReturnValue('signed-jwt');
+ });
+
+ it('should have correct properties', () => {
+ expect(credential.name).toBe('salesforceJwtApi');
+ expect(credential.displayName).toBe('Salesforce JWT API');
+ expect(credential.documentationUrl).toBe('salesforce');
+ expect(credential.test.request.baseURL).toBe(
+ '={{$credentials?.myDomainUrl ? $credentials.myDomainUrl.replace(/\\/$/, "") : ($credentials?.environment === "sandbox" ? "https://test.salesforce.com" : "https://login.salesforce.com")}}',
+ );
+ expect(credential.test.request.url).toBe('/services/oauth2/userinfo');
+ });
+
+ describe('resolveAuthUrl', () => {
+ it('defaults to login.salesforce.com for production when My Domain URL is empty', () => {
+ expect(
+ resolveAuthUrl({ ...baseCredentials, environment: 'production', myDomainUrl: '' }),
+ ).toBe('https://login.salesforce.com');
+ });
+
+ it('defaults to test.salesforce.com for sandbox when My Domain URL is empty', () => {
+ expect(resolveAuthUrl({ ...baseCredentials, environment: 'sandbox', myDomainUrl: '' })).toBe(
+ 'https://test.salesforce.com',
+ );
+ });
+
+ it('falls back to the default when My Domain URL is missing', () => {
+ expect(resolveAuthUrl({ ...baseCredentials, environment: 'production' })).toBe(
+ 'https://login.salesforce.com',
+ );
+ });
+
+ it('uses the My Domain URL override when provided', () => {
+ expect(
+ resolveAuthUrl({
+ ...baseCredentials,
+ environment: 'production',
+ myDomainUrl: 'https://acme.my.salesforce.com',
+ }),
+ ).toBe('https://acme.my.salesforce.com');
+ });
+
+ it('strips a trailing slash from the My Domain URL', () => {
+ expect(
+ resolveAuthUrl({
+ ...baseCredentials,
+ environment: 'sandbox',
+ myDomainUrl: 'https://acme--sandbox.sandbox.my.salesforce.com/',
+ }),
+ ).toBe('https://acme--sandbox.sandbox.my.salesforce.com');
+ });
+
+ it('prefers the My Domain URL over the environment setting', () => {
+ expect(
+ resolveAuthUrl({
+ ...baseCredentials,
+ environment: 'sandbox',
+ myDomainUrl: 'https://acme.my.salesforce.com',
+ }),
+ ).toBe('https://acme.my.salesforce.com');
+ });
+ });
+
+ describe('authenticate', () => {
+ it('signs the JWT with the sandbox default audience when no My Domain URL is set', async () => {
+ await credential.authenticate(
+ { ...baseCredentials, environment: 'sandbox', myDomainUrl: '' },
+ requestOptions,
+ );
+
+ expect(mockedSign).toHaveBeenCalledWith(
+ expect.objectContaining({ aud: 'https://test.salesforce.com' }),
+ expect.any(String),
+ expect.any(Object),
+ );
+ expect(mockedAxios).toHaveBeenCalledWith(
+ expect.objectContaining({
+ url: 'https://test.salesforce.com/services/oauth2/token',
+ }),
+ );
+ });
+
+ it('signs the JWT with the production default audience when no My Domain URL is set', async () => {
+ await credential.authenticate(
+ { ...baseCredentials, environment: 'production', myDomainUrl: '' },
+ requestOptions,
+ );
+
+ expect(mockedSign).toHaveBeenCalledWith(
+ expect.objectContaining({ aud: 'https://login.salesforce.com' }),
+ expect.any(String),
+ expect.any(Object),
+ );
+ expect(mockedAxios).toHaveBeenCalledWith(
+ expect.objectContaining({
+ url: 'https://login.salesforce.com/services/oauth2/token',
+ }),
+ );
+ });
+
+ it("signs the JWT with the Spring '26 sandbox My Domain URL as audience (issue #28990)", async () => {
+ await credential.authenticate(
+ {
+ ...baseCredentials,
+ environment: 'sandbox',
+ myDomainUrl: 'https://acme--sandbox.sandbox.my.salesforce.com',
+ },
+ requestOptions,
+ );
+
+ expect(mockedSign).toHaveBeenCalledWith(
+ expect.objectContaining({ aud: 'https://acme--sandbox.sandbox.my.salesforce.com' }),
+ expect.any(String),
+ expect.any(Object),
+ );
+ expect(mockedAxios).toHaveBeenCalledWith(
+ expect.objectContaining({
+ url: 'https://acme--sandbox.sandbox.my.salesforce.com/services/oauth2/token',
+ }),
+ );
+ });
+
+ it('signs the JWT with a production My Domain URL as audience', async () => {
+ await credential.authenticate(
+ {
+ ...baseCredentials,
+ environment: 'production',
+ myDomainUrl: 'https://acme.my.salesforce.com',
+ },
+ requestOptions,
+ );
+
+ expect(mockedSign).toHaveBeenCalledWith(
+ expect.objectContaining({ aud: 'https://acme.my.salesforce.com' }),
+ expect.any(String),
+ expect.any(Object),
+ );
+ expect(mockedAxios).toHaveBeenCalledWith(
+ expect.objectContaining({
+ url: 'https://acme.my.salesforce.com/services/oauth2/token',
+ }),
+ );
+ });
+
+ it('normalizes a trailing slash in the My Domain URL before signing', async () => {
+ await credential.authenticate(
+ {
+ ...baseCredentials,
+ environment: 'sandbox',
+ myDomainUrl: 'https://acme--sandbox.sandbox.my.salesforce.com/',
+ },
+ requestOptions,
+ );
+
+ expect(mockedSign).toHaveBeenCalledWith(
+ expect.objectContaining({ aud: 'https://acme--sandbox.sandbox.my.salesforce.com' }),
+ expect.any(String),
+ expect.any(Object),
+ );
+ expect(mockedAxios).toHaveBeenCalledWith(
+ expect.objectContaining({
+ url: 'https://acme--sandbox.sandbox.my.salesforce.com/services/oauth2/token',
+ }),
+ );
+ });
+
+ it('attaches the returned access token to the outgoing request', async () => {
+ const result = await credential.authenticate(
+ { ...baseCredentials, environment: 'production', myDomainUrl: '' },
+ requestOptions,
+ );
+
+ expect(result.headers?.Authorization).toBe('Bearer abc123');
+ });
+ });
+});
diff --git a/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts b/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts
index 78c52bfc3bb..54f50b06e4a 100644
--- a/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts
+++ b/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts
@@ -4,6 +4,7 @@ import { DateTime } from 'luxon';
import type {
IExecuteFunctions,
ILoadOptionsFunctions,
+ ICredentialDataDecryptedObject,
IDataObject,
INodePropertyOptions,
JsonObject,
@@ -13,6 +14,8 @@ import type {
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
+import { resolveAuthUrl } from '../../credentials/SalesforceJwtApi.credentials';
+
function getOptions(
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
method: IHttpRequestMethods,
@@ -42,13 +45,10 @@ function getOptions(
async function getAccessToken(
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
- credentials: IDataObject,
+ credentials: ICredentialDataDecryptedObject,
): Promise {
const now = moment().unix();
- const authUrl =
- credentials.environment === 'sandbox'
- ? 'https://test.salesforce.com'
- : 'https://login.salesforce.com';
+ const authUrl = resolveAuthUrl(credentials);
const signature = jwt.sign(
{
diff --git a/packages/nodes-base/nodes/Salesforce/__test__/GenericFunctions.test.ts b/packages/nodes-base/nodes/Salesforce/__test__/GenericFunctions.test.ts
index 762c946d73b..537dba143f7 100644
--- a/packages/nodes-base/nodes/Salesforce/__test__/GenericFunctions.test.ts
+++ b/packages/nodes-base/nodes/Salesforce/__test__/GenericFunctions.test.ts
@@ -622,6 +622,42 @@ describe('Salesforce -> GenericFunctions', () => {
});
});
+ it("should use the My Domain URL as JWT audience and token endpoint when set (Spring '26)", async () => {
+ const mockCredentials = {
+ clientId: 'test-client-id',
+ username: 'test@example.com',
+ privateKey: 'mock-private-key',
+ environment: 'sandbox',
+ myDomainUrl: 'https://acme--sandbox.sandbox.my.salesforce.com',
+ };
+ const mockResponse = {
+ access_token: 'my-domain-access-token',
+ instance_url: 'https://acme--sandbox.sandbox.my.salesforce.com',
+ };
+
+ mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
+ mockRequest.mockResolvedValue(mockResponse);
+ mockExecuteFunctions.logger = {
+ debug: jest.fn(),
+ } as any;
+
+ await salesforceApiRequest.call(mockExecuteFunctions, 'GET', '/test-endpoint', {}, {});
+
+ expect(mockJwt.sign as jest.Mock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ aud: 'https://acme--sandbox.sandbox.my.salesforce.com',
+ }),
+ 'mock-private-key',
+ expect.any(Object),
+ );
+
+ expect(mockRequest).toHaveBeenCalledWith(
+ expect.objectContaining({
+ uri: 'https://acme--sandbox.sandbox.my.salesforce.com/services/oauth2/token',
+ }),
+ );
+ });
+
it('should handle JWT token exchange with body and query parameters', async () => {
const mockCredentials = {
clientId: 'test-client-id',