SuperAPI AI Directory

arc-sf-jwt-bearer

Security & auditing

View repository

Updated 10-16 05:00

About this skill

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://.my.salesforce.com --alias


Ask the user which org to target if ambiguous.

### Step 2: Generate RSA Keypair

```bash
# 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 .key file is the private key — stored in your app's env vars (e.g., SF_PRIVATE_KEY)
  • The .crt file is the certificate — uploaded to the Salesforce Connected App
  • Never commit the .key file to version control
  • Clean up /tmp files 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:

  1. Navigate: Setup → App Manager → New Connected App (or External Client App Manager in newer orgs)
  2. Basic Info:
    • Connected App Name: <descriptive name>
    • API Name: <auto-generated or custom>
    • Contact Email: user's email
  3. 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)
  4. JWT Bearer Flow:
    • Enable JWT Bearer Flow: ✅
    • Upload certificate: the .crt file from Step 2
  5. Save and copy the Consumer Key (Client ID)

Step 4: Pre-authorize the Integration User (HITL)

After the Connected App is created:

  1. Go to the Connected App → Manage → Edit Policies
  2. Set Permitted Users to Admin approved users are pre-authorized
  3. Under Profiles, add the integration user's profile (e.g., System Administrator)
  4. Under Permission Sets, add relevant permission sets (e.g., External_App_Integration)
  5. 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 pendingConnection promise
  • \n escape handling for PEM keys stored in env vars
  • jsforce Connection wrapping with instanceUrl + 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.com for production, https://test.salesforce.com for sandboxes
  • Clean up temp files: Always delete /tmp/*.key and /tmp/*.crt after securing the credentials