Salesforce JWT Bearer Flow
Generates RSA keypairs, builds JWT assertions, exchanges them for access tokens, and validates Salesforce org connections. Handles the full lifecycle of JWT bearer authentication for server-to-server integration.
When to Use
- Setting up a new Connected App with JWT bearer flow
- Generating RSA keypairs for Salesforce authentication
- Testing JWT bearer token exchange against any Salesforce org
- Troubleshooting "invalid_grant" or "user hasn't approved this consumer" errors
- Configuring integration users for BFF/API layers
Workflow
Step 1: Identify Target Org
# List authenticated orgs
sf org list
# If org not authenticated yet
sf org login web --instance-url https://<instance>.my.salesforce.com --alias <alias>
Ask the user which org to target if ambiguous.
Step 2: Generate RSA Keypair
# Generate 2048-bit RSA private key
openssl genrsa -out /tmp/sf_jwt_server.key 2048
# Generate self-signed X.509 certificate (2-year validity)
openssl req -new -x509 \
-key /tmp/sf_jwt_server.key \
-out /tmp/sf_jwt_server.crt \
-days 730 \
-subj "/CN=<app-name>/O=<org-name>/C=US"
Important:
- The
.keyfile is the private key — stored in your app's env vars (e.g.,SF_PRIVATE_KEY) - The
.crtfile is the certificate — uploaded to the Salesforce Connected App - Never commit the
.keyfile to version control - Clean up
/tmpfiles after storing the key securely
Step 3: Guide Connected App Creation (HITL)
The user must create the Connected App manually in Salesforce Setup. Provide these instructions:
- Navigate: Setup → App Manager → New Connected App (or External Client App Manager in newer orgs)
- Basic Info:
- Connected App Name:
<descriptive name> - API Name:
<auto-generated or custom> - Contact Email: user's email
- Connected App Name:
- OAuth Settings:
- Enable OAuth: ✅
- Callback URL:
https://login.salesforce.com/services/oauth2/callback - OAuth Scopes:
Full access (full)+Perform requests at any time (refresh_token, offline_access)
- JWT Bearer Flow:
- Enable JWT Bearer Flow: ✅
- Upload certificate: the
.crtfile from Step 2
- Save and copy the Consumer Key (Client ID)
Step 4: Pre-authorize the Integration User (HITL)
After the Connected App is created:
- Go to the Connected App → Manage → Edit Policies
- Set Permitted Users to
Admin approved users are pre-authorized - Under Profiles, add the integration user's profile (e.g.,
System Administrator) - Under Permission Sets, add relevant permission sets (e.g.,
External_App_Integration) - Save
Without pre-authorization, JWT token exchange will fail with: "error": "invalid_grant", "error_description": "user hasn't approved this consumer"
Step 5: Assign Permission Sets
# Assign the integration permission set
sf org assign permset --name <PermissionSetName> --target-org <alias>
Step 6: Test JWT Bearer Flow
Use this Node.js script to test the JWT token exchange:
const crypto = require('crypto');
const fs = require('fs');
const loginUrl = '<SF_LOGIN_URL>'; // https://login.salesforce.com or https://test.salesforce.com
const clientId = '<CONSUMER_KEY>';
const username = '<INTEGRATION_USERNAME>';
const privateKey = fs.readFileSync('/tmp/sf_jwt_server.key', 'utf8');
// Build JWT assertion
const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString('base64url');
const now = Math.floor(Date.now() / 1000);
const payload = Buffer.from(JSON.stringify({
iss: clientId,
sub: username,
aud: loginUrl,
exp: now + 300
})).toString('base64url');
const signingInput = `${header}.${payload}`;
const sign = crypto.createSign('RSA-SHA256');
sign.update(signingInput);
const signature = sign.sign(privateKey, 'base64url');
const assertion = `${signingInput}.${signature}`;
// Exchange assertion for access token
const body = new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion
});
fetch(`${loginUrl}/services/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString()
}).then(r => r.json()).then(d => {
if (d.access_token) {
console.log('SUCCESS: Connected to', d.instance_url);
console.log('Access token length:', d.access_token.length);
} else {
console.log('FAILED:', JSON.stringify(d, null, 2));
}
}).catch(e => console.log('ERROR:', e.message));
Run with: node /tmp/test_jwt.js
Step 7: Store Credentials
After successful verification, store the credentials securely:
Vercel Environment Variables
vercel env add SF_CLIENT_ID <scope> --sensitive --yes
vercel env add SF_USERNAME <scope> --sensitive --yes
vercel env add SF_PRIVATE_KEY <scope> --sensitive --yes # Use --sensitive for multi-line PEM
vercel env add SF_LOGIN_URL <scope> --yes
.env.local (local development)
SF_LOGIN_URL=https://login.salesforce.com
SF_CLIENT_ID=<consumer-key>
SF_USERNAME=<integration-username>
SF_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\n<base64-encoded-key>\n-----END PRIVATE KEY-----
Note on PEM format: The private key can be stored with literal \n escape sequences. The application code should handle this with .replace(/\\n/g, "\n") before signing. When using vercel env add --sensitive, actual newlines are stored natively.
GitHub Actions Secrets
gh secret set SF_CLIENT_ID
gh secret set SF_USERNAME
gh secret set SF_PRIVATE_KEY
Step 8: Clean Up
# Remove temp key files after storing securely
rm -f /tmp/sf_jwt_server.key /tmp/sf_jwt_server.crt /tmp/test_jwt.js
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
invalid_grant: user hasn't approved this consumer |
Integration user not pre-authorized | Step 4: Edit Connected App policies, set "Admin approved users are pre-authorized", add profile/permission set |
invalid_grant: authentication failure |
Wrong client ID, username, or private key | Verify all three values match the Connected App and target org |
invalid_client_id |
Consumer Key doesn't match any Connected App | Copy the Consumer Key from the Connected App detail page |
invalid_grant: IP restricted |
Login IP ranges enforced on profile | Add the server's IP to the profile's Login IP Ranges, or remove IP restrictions for the integration profile |
TypeError: Invalid id value for this SObject type |
Hardcoded SObject ID prefixes in tests | Use SObjectType.getDescribe().getKeyPrefix() for dynamic ID generation |
| PEM parsing error | Private key format corrupted | Ensure -----BEGIN PRIVATE KEY----- header is present, newlines are correct |
JWT Bearer Flow Anatomy
┌─────────────┐ JWT Assertion ┌──────────────────┐
│ Your App │ ──────────────────→ │ Salesforce OAuth │
│ (BFF/API) │ (RS256 signed) │ /services/oauth2/ │
│ │ ←────────────────── │ token │
│ │ access_token + │ │
│ │ instance_url │ │
└─────────────┘ └──────────────────┘
JWT Assertion = header.payload.signature
header: { "alg": "RS256" }
payload: { "iss": clientId, "sub": username, "aud": loginUrl, "exp": now+300 }
signature: RS256(header.payload, privateKey)
Reference Code
The production implementation pattern is in apps/web/src/lib/salesforce.ts:
- Token caching with 2-hour lifetime and 5-minute refresh buffer
- Concurrent request deduplication via
pendingConnectionpromise \nescape handling for PEM keys stored in env vars- jsforce
Connectionwrapping withinstanceUrl+accessToken
Guidelines
- Always test before storing: Run the JWT test script (Step 6) before configuring env vars
- Separate keys per environment: Generate distinct keypairs for dev/preview/production
- Minimum permissions: Use dedicated integration permission sets, not System Administrator in production
- Certificate expiry: Track the 2-year certificate expiration and rotate before it expires
- Login URL matters: Use
https://login.salesforce.comfor production,https://test.salesforce.comfor sandboxes - Clean up temp files: Always delete
/tmp/*.keyand/tmp/*.crtafter securing the credentials