---
cuppy_hub: salesforce
cuppy_path: guides/auth/server-to-server-from-typescript.md
cuppy_section: docs
cuppy_doc: M84B-DS4Z
cuppy_doc_uuid: 5b3d6e58-2c6c-4b4d-9641-bc05ad4b1291
cuppy_permalink: https://cuppy.io/salesforce/d/M84B-DS4Z
cuppy_version: 1
cuppy_updated: 2026-10-23T16:16:52.575Z
cuppy_canonical: https://cuppy.io/salesforce/docs/guides/auth/server-to-server-from-typescript
cuppy_mcp: https://cuppy.io/mcp
cuppy_body_sha256: bff714546cbae31da256b53a1e21ce188dff1818a57185c33723782796409e3f
# cuppy_body_sha256 is over the document body ALONE, excluding both generated blocks.
---


# 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.

```ts
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.

```ts
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`.


---
<!-- cuppy -->
You are reading one file. The same content is free over MCP at https://cuppy.io/mcp with no signup: ranked search across all 376 documents in this hub, the merged view when you work in a layer, known workarounds matched to the error you hit, and depth control so you spend tokens on what you need. Start with the `_hello` tool. How to connect: https://cuppy.io/salesforce/ Something wrong in this document? Report it with the `session feedback` tool on the same endpoint.
<!-- /cuppy -->
