Salesforce CLI JWT auth in GitHub Actions

Salesforce CLI JWT auth in GitHub Actions: no browser login

Reading Time: 19 min
Author: William Watson Published: February 13, 2026 Updated: October 15, 2026
Current: Top of article

A GitHub Actions runner has no browser, so a job that reaches a browser login waits for a prompt nobody can answer. JWT bearer auth is the way round that: the org trusts a certificate, the job holds the matching private key, and sf org login jwt logs in without a prompt. Below is the setup I use: the app in the org, the key pair, the four secrets, a workflow that validates every pull request, the failures I meet most, and what keeps the token out of the log.

The Salesforce CLI DevOps Playbook covers the whole pipeline. This post is the auth step on its own, with the workflow file whole.

What is Salesforce CLI headless auth in CI/CD?

Headless auth means the job logs in with a signed JSON Web Token, a username and the consumer key of an app in the org, with no browser in the loop. Salesforce’s help for sf org login jwt says to use it in automated environments where you cannot log in with a browser, such as CI/CD scripts.

Core command:

sf org login jwt \
  --client-id "$SF_CLIENT_ID" \
  --jwt-key-file "$SF_JWT_KEY_PATH" \
  --username "$SF_USERNAME" \
  --instance-url https://login.salesforce.com

What do I need before configuring GitHub Actions?

You need an app in the target org that holds your certificate, a user that app pre-authorises, and an RSA key pair. The Salesforce DX guide says the CLI needs an external client app, which it calls preferred, or a connected app. Create a connected app only if the org is your Dev Hub and you will create scratch orgs or sandboxes from it. Salesforce disabled creating new connected apps by default in Spring ‘26 and is deprecating them. An org that still needs one asks Salesforce Customer Support to enable an org permission, once per org. The DX guide has a page for creating each kind of app. For JWT the app needs your certificate, server.crt from the next section, and the user pre-authorised to use it.

Before the first run you need:

  • The app’s consumer key (SF_CLIENT_ID), which the DX guide says to pass with --client-id.
  • Integration username (SF_USERNAME), pre-authorised for the app; the command’s help says the user must be approved to use it.
  • Private key file available to the workflow (server.key).
  • Correct login domain (https://login.salesforce.com or sandbox URL).

How do I generate a key pair for JWT auth?

Generate the key pair once, upload the certificate to the external client app, and keep the private key out of git.

openssl genrsa -out server.key 2048
openssl req -new -key server.key -out server.csr
openssl x509 -req -sha256 -days 365 -in server.csr -signkey server.key -out server.crt

Upload server.crt when you create the external client app. The DX guide’s key and certificate page says that file is the one you upload to the app, and server.key is the one you pass to org login jwt. Salesforce’s own steps start from a passphrased key and strip the passphrase. The three commands above are the shorter route I use to the same two files. Store server.key as a GitHub secret, which the next section covers.

Which GitHub secrets should I create?

Store the four values as repository or environment secrets and rebuild the key file at run time. GitHub’s secrets guide allows Base64 for small binary blobs, which is how server.key travels, and says Base64 is not a substitute for encryption.

The four secrets I create:

  • SF_CLIENT_ID
  • SF_USERNAME
  • SF_JWT_KEY_BASE64 (base64-encoded contents of server.key)
  • SF_INSTANCE_URL (https://login.salesforce.com or sandbox endpoint)

What does a minimal GitHub Actions workflow look like?

Install Node and the Salesforce CLI, restore the JWT key, log in with sf org login jwt, then validate the deploy. Keep the login and the deploy in the same job. GitHub runs each job in its own runner, so a login in one job is not there in the next. The CLI’s login files hold the session, and shipping them across as an artifact puts that session in a downloadable file, so I do not split the jobs.

name: Salesforce Validate Deploy

on:
  pull_request:
    branches: [main]

jobs:
  validate:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v7

      - name: Setup Node
        uses: actions/setup-node@v7
        with:
          node-version: 22

      - name: Install Salesforce CLI
        run: npm install @salesforce/cli@2.154.4 --global

      - name: Restore JWT key
        run: |
          echo "$SF_JWT_KEY_BASE64" | base64 --decode > server.key
          chmod 600 server.key
        env:
          SF_JWT_KEY_BASE64: ${{ secrets.SF_JWT_KEY_BASE64 }}

      - name: Authenticate to Salesforce
        run: |
          sf org login jwt \
            --client-id "$SF_CLIENT_ID" \
            --jwt-key-file server.key \
            --username "$SF_USERNAME" \
            --instance-url "$SF_INSTANCE_URL"
          # current releases print [REDACTED] for the access token
          sf org display --target-org "$SF_USERNAME"
        env:
          SF_CLIENT_ID: ${{ secrets.SF_CLIENT_ID }}
          SF_USERNAME: ${{ secrets.SF_USERNAME }}
          SF_INSTANCE_URL: ${{ secrets.SF_INSTANCE_URL }}

      - name: Validate deploy
        run: |
          sf project deploy validate \
            --source-dir force-app \
            --target-org "$SF_USERNAME" \
            --test-level RunLocalTests \
            --wait 60
        env:
          SF_USERNAME: ${{ secrets.SF_USERNAME }}

The versions are pinned on purpose. actions/checkout@v7 and actions/setup-node@v7 run on Node 24; GitHub removed Node 20 from its runners on 23 September 2026 and asks for current versions of the actions. Node 22 is the oldest release the CLI’s package accepts, and Node 20 reached end of life on 30 April 2026. @salesforce/cli is pinned at 2.154.4, the npm latest tag on 15 October 2026. Pin a release you have tested and bump it deliberately, so the CLI does not change under the job between runs. The old pin in this post, 2.75.0, bundled a plugin-org whose sf org display printed the live access token in its table, straight into the Actions log. GitHub’s secrets guide says anything that is not a GitHub secret needs ::add-mask:: to be redacted from the log, and a session token the CLI fetched is not a secret. In plugin-org 6.0.18, which 2.154.4 bundles, the token prints as [REDACTED], and sf org auth show-access-token shows it when you want it on purpose.

How do I fix common JWT auth failures in Actions?

In my projects the failures have been setup mismatches. Check the username, the app’s consumer key, the certificate and the instance URL first.

The checks I run first:

  • invalid_grant: in my projects this has meant the wrong username, a certificate that does not match the key, or a user the app does not pre-authorise. The command’s help says the user must be approved to use the app.
  • audience is invalid: the CLI builds the audience itself. It takes SF_AUDIENCE_URL when that is set, otherwise https://login.salesforce.com, gs1 orgs aside, and then tries login and audience pairs that include https://test.salesforce.com and the --instance-url you gave. The flag is the first audience tried, not the only one. When every pair fails, the error lists each attempt with its audience and login. Read that list, then set SF_AUDIENCE_URL in the job’s env to the host the org expects.
  • A key error on every run: in my projects this has been the secret pasted without base64 encoding, or a decode step that wrote an empty file. A wc -c server.key after the restore step shows it at once.
  • No authorization information found for <username>: the login ran in another job, or the username in the deploy step is not the one that logged in. The login and the deploy stay in one job.

How do I keep this secure in production pipelines?

I run the job as a dedicated integration user with only the permissions the deploy needs, keep production secrets in a GitHub environment with required reviewers, and rotate the certificate on a schedule.

What I hold to:

  • Restrict the app’s policies to approved users. The DX guide’s JWT setup selects admin-approved users as pre-authorised and names the profiles or permission sets allowed in.
  • Put production secrets in a GitHub environment with required reviewers. GitHub says a job that uses the environment can read its secrets only after the configured rules pass.
  • Rotate the certificate and key on a schedule, and when someone who held the key leaves.
  • Keep the sf org display check after the login so a bad login fails before any metadata moves. On current releases it prints [REDACTED] for the token. Never set SF_TEMP_SHOW_SECRETS in CI; it is the temporary switch that shows the token again, and Salesforce removes it on 28 October 2026.

For deployment command strategy after auth, continue with the post on which sf project deploy command to run in each environment.

References

Working through something like this on your own org?

I take on contract work: scoped Apex, Lightning Web Components, integration and pipeline deliverables, written by me and shipped with tests and documentation. The services page says what I take on and how an engagement runs.

See how I take on contract work