Connect a Node.js backend to Salesforce without passwords — client credentials and JWT bearer with jsforce, token refresh, API version, error codes
A compiling TypeScript path from a backend to a Salesforce org with no stored passwords — an External Client App with the client credentials flow (token from the My Domain, Run As user, re-issue on 401 through jsforce's refreshFn) or the JWT bearer flow (a certificate, an RS256 assertion signed with node:crypto), setting the API version jsforce otherwise defaults to 50.0, the same login for the Salesforce CLI, and the error codes to branch on.
markdownguidev1M84B-DS4Zstage: buildtrust: officialservice: authinterface: sdkinterface: clijsforce client credentialsjsforce JWT bearerConnection authorize grant_type client_credentialsurn:ietf:params:oauth:grant-type:jwt-bearerrefreshFnjsforce version 50.0 defaultjsforce errorCodeconn.identitynode:crypto RS256 JWTopenssl req -x509 server.key server.crtsf org login jwt--jwt-key-file--client-id--instance-urlsf api request rest /services/data/token caching
Connect a Node.js backend to Salesforce without passwords
Pick the flow in reference/auth/external-client-apps-and-oauth-flows.md:
- client credentials when a client secret in a secret store is acceptable;
- JWT bearer when you'd rather hold a private key.
Both need an External Client App and a dedicated integration user (reference/access/users-permissions-and-agent-users.md).
Client credentials
Prerequisites on the ECA:
- Enable Client Credentials Flow, with a Run As (Username) on the Policies tab;
- scope
api, plus the Agent API scopes if you need them.
import { Connection } from "jsforce";
// Client credentials: an External Client App with "Enable Client Credentials Flow" and a Run As user.
// Token requests must go to the org's My Domain, not login.salesforce.com. Secrets come from your secret store.
const myDomain = process.env.SF_MY_DOMAIN ?? ""; // https://<my-domain>.my.salesforce.com
export async function connect(): Promise<Connection> {
const conn = new Connection({
oauth2: { clientId: process.env.SF_CLIENT_ID ?? "", clientSecret: process.env.SF_CLIENT_SECRET ?? "", loginUrl: myDomain },
version: "66.0", // jsforce defaults to 50.0: always set the version your org supports
// No refresh token in this flow: on a 401, jsforce calls this to get a new access token
refreshFn: (c, callback) => {
c.oauth2
.requestToken({ grant_type: "client_credentials" })
.then((res) => callback(null, res.access_token, res))
.catch((err: Error) => callback(err));
},
});
await conn.authorize({ grant_type: "client_credentials" }); // sets instanceUrl and accessToken
return conn;
}
export async function whoAmI(): Promise<void> {
const conn = await connect();
try {
const id = await conn.identity(); // the Run As user
console.log(id.username, conn.instanceUrl);
} catch (e) {
const code = (e as { errorCode?: string }).errorCode; // jsforce puts the API error code here
if (code === "INVALID_SESSION_ID") console.error("token expired or revoked, and the refresh failed");
else if (code === "API_DISABLED_FOR_ORG") console.error("the org's edition or the user's profile has no API access");
throw e;
}
}
JWT bearer
- Create a key and a self-signed certificate:
openssl req -x509 -newkey rsa:2048 -nodes -keyout server.key -out server.crt -days 365 -subj "/CN=sf-integration". Keepserver.keyin your secret store; upload onlyserver.crtto the ECA. - On the ECA:
- Enable JWT Bearer Flow;
- upload the certificate;
- set Permitted Users = Admin approved users are pre-authorized;
- add the integration user's profile or permission set.
import { createSign } from "node:crypto";
import { readFileSync } from "node:fs";
import { Connection } from "jsforce";
// JWT bearer: an External Client App with "Enable JWT Bearer Flow", your certificate uploaded,
// Permitted Users = "Admin approved users are pre-authorized", and the user's profile or permission set added.
function assertion(clientId: string, username: string, audience: string, keyPem: string): string {
const b64 = (o: object) => Buffer.from(JSON.stringify(o)).toString("base64url");
const head = b64({ alg: "RS256" });
const claims = b64({ iss: clientId, sub: username, aud: audience, exp: Math.floor(Date.now() / 1000) + 180 });
const sig = createSign("RSA-SHA256").update(`${head}.${claims}`).sign(keyPem, "base64url");
return `${head}.${claims}.${sig}`;
}
export async function connectWithJwt(): Promise<Connection> {
const loginUrl = "https://login.salesforce.com"; // sandboxes: https://test.salesforce.com, or the My Domain URL
const conn = new Connection({ oauth2: { clientId: process.env.SF_CLIENT_ID ?? "", loginUrl }, version: "66.0" });
await conn.authorize({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: assertion(process.env.SF_CLIENT_ID ?? "", process.env.SF_USERNAME ?? "", loginUrl, readFileSync("server.key", "utf8")),
});
return conn; // no refresh token: sign a new assertion when the access token expires
}
Salesforce's CLI builds the same assertion (iss = consumer key, sub = username, aud = the login URL, RS256). Because the right audience isn't always obvious, it tries several login and audience combinations.
The CLI logs in the same way, for deploys and CI:
sf org login jwt --username <integration-user> --jwt-key-file server.key --client-id <consumer-key> --instance-url https://<my-domain>.my.salesforce.com --alias ci
Then
- API version: jsforce falls back to 50.0. Set
version, and check what the org serves withsf api request rest /services/data/ --target-org ci(reference/orgs/orgs-and-editions.md). - Reuse tokens. Request one per process, not per call; token loops end in
rate_limit_exceeded. - Errors: jsforce puts the API code on
errorCode(INVALID_SESSION_ID,REQUEST_LIMIT_EXCEEDED…). Token endpoint failures carryerroranderror_description:reference/auth/oauth-and-session-errors.md.