Every few months someone asks me the same question: "our middleware needs to call Salesforce every night, how should it log in?" And every few months I find another org where the answer was a hardcoded username and password sitting in a properties file. The JWT Bearer flow exists precisely so you never have to do that. This is the setup I use on real projects, including the parts that usually go wrong.

Why JWT Bearer and not something else

The JWT Bearer flow is built for server-to-server integration where no human is present to click "Allow". Instead of a password, the external system proves its identity by signing a small JSON payload (the JWT) with a private key. Salesforce checks the signature against the certificate you uploaded to the Connected App, and if everything matches, it hands back a short-lived access token.

The practical benefits are hard to argue with:

  • No password or client secret travels over the wire after setup. If someone captures the request, they get a token that expires, not a credential.
  • Password rotation policies stop breaking your integration, because there is no password involved.
  • Access is pre-authorized per user through the Connected App, so you decide exactly who the integration can act as.

Salesforce itself has pushed this flow as the standard for automated integrations, and both the Username-Password flow and long-lived session IDs are patterns I actively remove from orgs when I find them.

Step 1: create the certificate and key pair

You need an X.509 certificate. The private key stays with the calling system, the public certificate gets uploaded to Salesforce. For most projects a self-signed certificate is perfectly fine here, since Salesforce only uses it to verify signatures, not to establish trust chains.

# Private key + self-signed cert, valid 2 years
openssl req -x509 -sha256 -nodes -days 730 \
  -newkey rsa:2048 \
  -keyout jwt_private.key \
  -out jwt_cert.crt \
  -subj "/CN=my-integration"

Guard jwt_private.key like a password. It goes in your secret manager (AWS Secrets Manager, Azure Key Vault, whatever your stack uses), never in the repo.

Step 2: configure the Connected App

In Setup, create a Connected App (or an External Client App if your org has moved to the newer model) with these settings:

  • Enable OAuth Settings, and set any callback URL. The JWT flow never uses it, but the field is required. https://login.salesforce.com/services/oauth2/callback works.
  • Check "Use digital signatures" and upload jwt_cert.crt.
  • Select scopes. You want api at minimum, plus refresh_token, offline_access if the consumer expects it. Resist the urge to grant full.

Then the part everyone forgets: after saving, go to Manage, then Edit Policies, and set "Permitted Users" to Admin approved users are pre-authorized. Assign a permission set or profile that includes your integration user. Without this, the flow fails no matter how correct your JWT is, because nobody ever consented interactively.

Use a dedicated integration user. Give it a Minimum Access profile plus a permission set with exactly the objects and fields the integration needs. When something writes garbage data at 3 a.m., you want the audit trail to point to one purpose-built user, not a shared admin account.

Step 3: build and sign the JWT

The JWT itself is three base64url-encoded parts: a header, a claims payload, and a signature. The claims Salesforce cares about:

{
  "iss": "3MVG9...your_consumer_key",
  "sub": "integration.user@yourcompany.com",
  "aud": "https://login.salesforce.com",
  "exp": 1789000000
}
  • iss is the Connected App consumer key.
  • sub is the username to act as. In a sandbox, remember usernames carry the sandbox suffix.
  • aud is https://login.salesforce.com for production and Developer Editions, https://test.salesforce.com for sandboxes. Not your My Domain URL. This one mistake accounts for half the failed setups I have debugged.
  • exp is a Unix timestamp, at most 3 minutes in the future. Keep it short, the token exchange happens immediately.

Sign the header and payload with RS256 using the private key. In Node.js, the jsonwebtoken package does this in a few lines:

const jwt = require('jsonwebtoken');
const fs = require('fs');

const token = jwt.sign(
  {
    iss: process.env.SF_CONSUMER_KEY,
    sub: process.env.SF_USERNAME,
    aud: 'https://login.salesforce.com'
  },
  fs.readFileSync('jwt_private.key'),
  { algorithm: 'RS256', expiresIn: '3m' }
);

Step 4: exchange the JWT for an access token

POST the assertion to the token endpoint:

POST https://login.salesforce.com/services/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
&assertion=eyJhbGciOiJSUzI1NiJ9...

A successful response gives you the access token plus the instance URL to use for API calls:

{
  "access_token": "00Dxx0000001gPz!AR8AQ...",
  "instance_url": "https://yourcompany.my.salesforce.com",
  "token_type": "Bearer",
  "scope": "api"
}

From here, every REST call carries Authorization: Bearer <access_token>. Cache the token and reuse it until it expires (session settings control the lifetime), then request a new one. Do not mint a fresh JWT per API call; that just burns CPU and rate limits.

What about the other direction, Salesforce calling out?

Everything above covers an external system logging into Salesforce. When Salesforce is the caller and the external API expects a JWT, do not hand-roll the signing in Apex. Create an External Credential with the JWT Bearer authentication protocol, upload the certificate under Certificate and Key Management, and reference it from a Named Credential. Apex then becomes trivial:

HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Partner_API/v2/orders');
req.setMethod('GET');
HttpResponse res = new Http().send(req);

Salesforce signs the JWT, exchanges it, caches the token, and injects the Authorization header for you. I wrote a separate guide on Named Credentials and External Credentials that covers this model in depth.

The errors you will actually hit

invalid_grant: user hasn't approved this consumer

The Connected App policy is not set to pre-authorized, or your integration user is not in the assigned permission set or profile. Fix the policy, not the JWT.

invalid_grant: audience is invalid

Your aud claim does not match the login server. Production and Dev Editions use login.salesforce.com, sandboxes use test.salesforce.com. My Domain URLs in the audience claim work in some configurations but I avoid relying on that.

invalid_grant: invalid assertion

Usually clock skew (your server's clock is off and exp is already in the past), an expired certificate, or the signature was made with a key that does not match the uploaded cert. Check the boring things first.

invalid_client_id

Freshly created Connected Apps take a few minutes to propagate. If you created the app 30 seconds ago, get a coffee and try again.

Checklist before you call it done

  • Private key lives in a secret manager, not in code or config files.
  • Dedicated integration user with least-privilege permissions.
  • Connected App restricted by IP or at least monitored via login history.
  • Token caching on the client side, with retry on 401.
  • A calendar reminder before the certificate expires. A two-year cert fails exactly two years later, usually on a weekend.