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.comor 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_IDSF_USERNAMESF_JWT_KEY_BASE64(base64-encoded contents ofserver.key)SF_INSTANCE_URL(https://login.salesforce.comor 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 takesSF_AUDIENCE_URLwhen that is set, otherwisehttps://login.salesforce.com, gs1 orgs aside, and then tries login and audience pairs that includehttps://test.salesforce.comand the--instance-urlyou 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 setSF_AUDIENCE_URLin the job’senvto 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.keyafter 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 displaycheck after the login so a bad login fails before any metadata moves. On current releases it prints[REDACTED]for the token. Never setSF_TEMP_SHOW_SECRETSin 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
- Salesforce,
sf org login jwthelp in plugin-auth: use it where you cannot log in with a browser, such as CI/CD scripts, and the user must be approved for the app. - Salesforce DX Developer Guide, Create a Connected App in Your Org: external client app preferred, and connected app only for a Dev Hub that creates scratch orgs or sandboxes. The same page says creation is disabled by default, that Support enables it once per org, and gives the JWT settings.
- Salesforce DX Developer Guide, Create an External Client App in Your Org: the app
org login jwtneeds and whereserver.crtgoes. - Salesforce DX Developer Guide, Create a Private Key and Self-Signed Digital Certificate:
server.keyfor the login,server.crtfor the app, and Salesforce’s own OpenSSL steps. - Salesforce CLI, plugin-org 6.0.18,
org displaysource and its redaction messages: the token prints as[REDACTED]unlessSF_TEMP_SHOW_SECRETSis set, and that switch goes on 28 October 2026. - Salesforce CLI, plugin-org 5.2.23,
org displaysource: the version bundled with CLI 2.75.0 printed the access token as it was. - Salesforce, sfdx-core
sfdcUrl.tsandauthInfo.ts:getJwtAudienceUrlwith theSF_AUDIENCE_URLoverride,getLoginAudienceCombos, and the login loop that tries each pair. - Salesforce, sfdx-core messages: the text of the missing-login error.
- npm, @salesforce/cli:
latest2.154.4 on 15 October 2026, and Node 22 or later inengines. - Node.js, Release schedule: Node 20 end of life 30 April 2026, Node 22 to 30 April 2027.
- GitHub Changelog, Deprecation of Node 20 on GitHub Actions runners: Node 24 the default from 16 June 2026, Node 20 removed on 23 September 2026.
- GitHub, actions/checkout and actions/setup-node: v7 of each runs on Node 24.
- GitHub Docs, Using secrets in GitHub Actions: secrets at organisation, repository or environment level, Base64 for small binary blobs, and
::add-mask::for values that are not secrets. - GitHub Docs, Managing environments for deployment: environment secrets readable only after the configured rules pass.
- GitHub Docs, Understanding GitHub Actions: each job runs in its own runner.
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.