Skip to content

Secrets Management and Security Scanning

The Salesforce Authentication Surface

Salesforce CI pipelines have a specific and well-defined authentication problem: the GitHub Actions runner needs to authenticate to one or more Salesforce orgs - sandboxes, scratch orgs, production - without storing credentials in the repository or passing them interactively. How this is solved has direct security implications that go beyond convenience.

The four patterns in common use, ordered from least to most recommended:

SFDX Auth URLs are the path of least resistance and the most dangerous. An SFDX auth URL encodes a refresh token, instance URL, and client credentials into a single string. If that string is committed to a repository - even briefly, even in a branch that is later deleted - it represents a persistent credential leak. Auth URLs should never be stored in repositories, should never be passed as plain environment variables in workflow YAML, and should be treated as equivalent in sensitivity to a private key. Their use in CI is discouraged; they are appropriate only for ephemeral local development flows. Since 27 May 2026 the Salesforce CLI treats them the same way: it no longer prints auth URLs, access tokens or passwords in the output of sf org display or the sf org login commands, and they can be retrieved only with the dedicated sf org auth show-sfdx-auth-url and sf org auth show-access-token commands, which need --json or --no-prompt in a pipeline (forcedotcom/cli#3560). Deny those commands to any AI coding agent that works in the repository. An auth URL also has a limited life where Salesforce's guidance is followed. Its documentation for the External Client App recommends, as a security best practice, that refresh tokens expire after 90 days and that sessions time out after 15 minutes; an auth URL embeds a refresh token, so one stored as a pipeline secret stops working when that token expires and has to be issued again.

Access/Opaque Token authentication is the simplest credential model - a Salesforce session token obtained from an authenticated org is stored directly as a GitHub Actions secret and passed to the Salesforce CLI via the SF_ACCESS_TOKEN environment variable or the --access-token flag. No External Client App configuration is required, and setup is minimal. The tradeoffs are significant enough that this approach should be considered a convenience option rather than a production governance pattern. Access tokens are session-based and expire - the default timeout is 2 hours but this is org-configurable, ranging from 15 minutes to 24 hours - meaning they require manual rotation or an automated refresh mechanism to remain valid in CI. An access token carries the identity of whichever user obtained it, so a token obtained as the dedicated integration user is attributed to that user, as it should be. The weakness lies elsewhere. The token is a copy of an existing session rather than a login of its own: Salesforce records a login when a session is created, not when a stored token is reused, so everything the pipeline does traces back to the one login in which the token was obtained and cannot be told apart from anything else done in that session. A JWT login, by contrast, is recorded each time it happens, against the External Client App. And obtaining the token means someone signing in as the integration user and copying its live credential by hand. For short-lived projects, developer sandboxes, or environments where External Client App configuration is not available, access token authentication is a practical starting point. For any environment mapped to a production or pre-production org, JWT Bearer Flow should be the standard.

JWT (JSON Web Token) Bearer Flow is the recommended authentication pattern for Salesforce CI pipelines. It requires an External Client App configured in the target org, with the JWT bearer flow enabled and its certificate uploaded, and a private key stored as a GitHub Actions secret. The workflow authenticates by generating a signed JWT assertion - no refresh token, no interactive login, no long-lived session credential. The private key never leaves GitHub's secrets infrastructure during workflow execution. This pattern works across production orgs, sandboxes, scratch orgs, and Developer Edition orgs, and is the most widely supported headless authentication mechanism across the Salesforce ecosystem.

The External Client App replaces the Connected App here. Since Spring '26, new Connected Apps can no longer be created, in Setup or through the Metadata API, unless Salesforce Support enables it; a Connected App already set up for the JWT bearer flow keeps working. Two things work differently from a Connected App. An External Client App authenticates only against an org where it is defined or installed, so each org the pipeline connects to needs the app: created in that org, or installed there from a package. And one case still needs a Connected App. Salesforce's CLI documentation says to create a Connected App instead when authorising a Dev Hub from which scratch orgs or sandboxes will be created; with an External Client App, scratch org creation fails (forcedotcom/cli#3515, open at the time of writing). A pipeline that creates scratch orgs therefore needs an existing Connected App for its Dev Hub, or Salesforce Support to enable the creation of one. An External Client App is deployable metadata, and one of its files, the global OAuth settings, holds the consumer key and consumer secret: that file is Tier 0 and never enters the repository (Appendix B).

GitHub OIDC (OpenID Connect) is an emerging pattern that eliminates the need to store even the private key as a long-lived secret - the GitHub Actions runner obtains a short-lived OIDC token that Salesforce validates against a configured identity provider. This is architecturally the most secure option but requires Salesforce-side configuration that is not yet universally available across all org types. Teams operating at high security maturity should evaluate this pattern; for most organisations, JWT Bearer Flow provides a strong and practical baseline.

All Salesforce authentication and actions for long-lived environments should be performed as a dedicated service/integration user, not as any human. This allows better auditability.

Credential Storage - GitHub Secrets Architecture

GitHub Actions secrets are the baseline. Secrets are encrypted at rest, masked in logs, and scoped to either a repository or an organization. Organization-scoped secrets - where the External Client App credentials for each Salesforce org are shared across all Salesforce repositories in the GitHub organization - provide a clean centralised model. This is the recommended starting point for most teams.

GitHub Environments add an approval gate layer on top of secrets. Environment-scoped secrets are only accessible to workflows targeting that environment, and environments can be configured to require manual approval before a workflow step executes. This is the appropriate model for production deployment credentials - the production External Client App private key should live in a protected GitHub Environment, not a plain repository secret.

External Secret Vaults - HashiCorp Vault, AWS Secrets Manager, Azure Key Vault - are appropriate for organisations with existing enterprise secrets infrastructure or compliance requirements that mandate centralised credential management outside of GitHub. The tradeoff is operational overhead: vault integration adds pipeline complexity and an external dependency that must be maintained.

Secret Scanning - Preventing Credential Leaks

Pre-commit secret scanning should be wired into Husky pre-commit hooks so that credential patterns are intercepted before they enter Git history - the earliest possible intervention point. A number of open source and commercial tools support this pattern, including Gitleaks (GitHub) and TruffleHog (GitHub). The choice of tool is less important than ensuring that the same credential pattern definitions govern both local pre-commit scanning and CI-time scanning, so that no divergence exists between what a developer's machine detects and what the pipeline enforces. The pattern definitions should be version-controlled and centralised in the configuration repository so they are consistent across all project repositories.

CI-time scanning runs the same tooling as a workflow step on every push and pull request, providing a safety net for contributors who have not configured local hooks or are working in environments where pre-commit hooks are bypassed. GitHub's native secret scanning - free for a public repository, and available for a private one with GitHub Secret Protection - provides an additional layer that operates across the entire repository history, not just new commits, and can alert on secrets detected in any branch. GitHub's push protection, which comes with the same plan, refuses a push that holds a credential that GitHub recognises, on GitHub's server, before the credential reaches the repository. It catches what a skipped or missing hook lets through, so every repository turns it on (Version Control: Secret Scanning).

The combination of pre-commit scanning, push protection, and CI-time scanning creates a defence-in-depth model where no single point of failure results in an undetected credential leak.

Branch Protection and Signed Commits as a Policy Layer

Branch protection rules and signed commit requirements are the GitHub-native mechanism for enforcing repository integrity as a policy, independent of individual contributor behaviour.

At minimum, the main branch and any environment-mapped branches should be protected with: PR required with review, required status checks that must pass before merge, and restrictions on force pushes and branch deletion.

Signed commits - requiring that all commits carry a verified cryptographic signature - provide an additional integrity guarantee. GitHub's Vigilant Mode surfaces unsigned commits visually in the repository interface. Enforcement via GitHub Rulesets can make signed commits a hard requirement for protected branches. The implementation details of GPG or SSH signing are outside the scope of this whitepaper, but the policy decision should be made explicitly and documented in the organisation's GitHub governance standards rather than left to individual teams.

Dependency Scanning - npm and Managed Packages

npm dependencies introduced by LWC development and used as sfdx devDependencies are the higher-velocity risk surface. Three complementary controls address this:

Dependabot is GitHub-native and requires no installation. It does two things. It raises an alert when a dependency has a known vulnerability, and opens a pull request for the version that corrects it; both are switched on in the settings of the repository or of the organisation. With a dependabot.yml configuration file it also opens pull requests for ordinary version updates, for the npm and github-actions ecosystems among others - workflow action dependencies carry their own supply chain risk and are frequently overlooked.

Mend Renovate is the more configurable alternative - and for organisations managing multiple Salesforce repositories, the more appropriate choice at scale. Renovate supports organization-wide shared configuration through one central file that the file of each repository extends, enabling consistent dependency update policies across all repositories. Its update grouping capability can batch related dependencies such as all @salesforce/* scoped packages into a single PR. Renovate is available as a free hosted GitHub App via Mend, or self-hosted. These standards recommend both tools, each for one job: Renovate for version updates, with its grouping and scheduling, and Dependabot for its native GitHub security alerts and security updates. The settings, and a sample of the central file, are in Version Control: Renovate and Dependabot.

Internal shared packages - such as a centralised Prettier configuration published as @<my-org>/prettier-config (described in Section 5) - are npm dependencies in the same way as any third-party package, and should be tracked by Dependabot and Renovate alongside external dependencies, ensuring that repositories consuming a pinned version of a shared config package receive automated PRs when a new version is published.

Whichever tool opens it, a pull request that changes package.json or package-lock.json is reviewed by the team that governs the pipeline, because CODEOWNERS gives those files to that team: the npm packages are the tools of the pipeline, and a new version can change what a gate accepts. A developer does not upgrade a package by hand (Version Control: Repositories).

RetireJS via Salesforce Code Analyzer scans LWC components and static resources for known vulnerable JavaScript dependencies as part of quality gates, providing a complementary signal to Dependabot and Renovate's update-focused approach - catching vulnerabilities in dependencies that are present in the codebase but have not yet triggered an update PR.

Managed packages are a lower-velocity but harder-to-govern risk surface. Installed managed packages are outside the repository's dependency graph - they do not appear in package.json - and package upgrades in a production org can introduce behavioral changes silently. Primary controls are process-based: documenting installed packages and versions in a versioned artifact, requiring change management approval for package upgrades, and running the full local test suite against the org after any package upgrade and before promoting further changes.

Further reading: