A CI secret should be a narrowly granted capability, not ambient process state inherited by every job. Put production credentials behind a protected environment, inject them into the deployment step only, and never execute untrusted pull-request code in that context.
Keep the scope close to the use
deploy:
environment: production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@<pinned-sha>
- name: Deploy
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: ./scripts/deploy
| Scope | Good fit | Risk |
|---|---|---|
| Repository secret | One repository's automation | Any permitted workflow can misuse it |
| Environment secret | Production deploy | Requires deliberate approval/configuration |
| Organisation secret | Shared service | Easy to overexpose to repositories |
DEPLOY_TOKEN is limited to the step rather than workflow-wide. That distinction matters when later steps run third-party actions or diagnostic shell commands.
Masking only hides recognised values in logs. It does not prevent a script from sending a secret to the network, encoding it, or writing it into an artifact.
Do not mix trusted and untrusted execution
Fork pull requests must not receive deploy secrets. pull_request_target runs with base-repository privileges, so checking out and executing the contributor's revision there can turn a documentation PR into credential theft. Test untrusted code with no secrets; deploy from a reviewed branch in a separate workflow or protected job.
Prefer short-lived identity
For cloud providers, GitHub's OIDC token can be exchanged for an expiring credential constrained by repository, branch, and environment. That removes a long-lived cloud key from GitHub entirely. Pin action versions to commit SHAs, use the minimum permissions, and make a deployment credential capable of only the one deployment operation it needs.
The safest secret is one that exists briefly, is usable in one place, and can do very little if it escapes.
