# Arc Sf JWT Bearer

> Generates RSA keypairs and tests JWT bearer token flow against Salesforce orgs. Use when setting up server-to-server auth, creating Connected Apps with JWT, or troubleshooting JWT bearer flow errors.

- Skill: `andysolomon/arc-sf-jwt-bearer` (Agent Skill)
- Install (CLI): `npx skillmds@latest add andysolomon/arc-sf-jwt-bearer`
- Raw SKILL.md: https://api.skillmd.com/api/skills/andysolomon/arc-sf-jwt-bearer/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: andysolomon (https://skillmd.com/u/andysolomon)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/andysolomon/arc-sf-jwt-bearer

---


# 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

```bash
# 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

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

```bash
# 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:

```javascript
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
```bash
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
```bash
gh secret set SF_CLIENT_ID
gh secret set SF_USERNAME
gh secret set SF_PRIVATE_KEY
```

### Step 8: Clean Up

```bash
# 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

