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

Created 23 Oct 2026 · Modified 23 Oct 2026 · 1 version

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

  1. 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". Keep server.key in your secret store; upload only server.crt to the ECA.
  2. 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 with sf 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 carry error and error_description: reference/auth/oauth-and-session-errors.md.

Read this document as markdown — the same bytes an agent receives.