Safeguard
Application Security•

How to Secure GitHub Actions Workflows, Step by Step

To secure GitHub Actions, set GITHUB_TOKEN to read-only, pin actions to commit SHAs, stop script injection, use OIDC, and protect the workflow files.

Safeguard Security Team
6 min read

To secure GitHub Actions, set the default GITHUB_TOKEN to read-only and raise permissions per job, pin third-party actions to full-length commit SHAs, pass untrusted input through environment variables, replace stored cloud keys with OpenID Connect, and require review on any change to the workflow files.

Key takeaways

  • Start with least privilege: read-only token by default, wider access only in the job that needs it.
  • A tag can be moved. A full-length commit SHA cannot.
  • Pull request titles, branch names and issue text are attacker-controlled input. Never paste them into a shell line.
  • OIDC gives each job a short-lived credential, so there is no long-lived cloud key to steal.
  • Workflow files are code. Protect them with CODEOWNERS and branch protection.

How do I limit what the GITHUB_TOKEN can do?

Set the default token permission to read-only for repository contents, then grant more inside individual jobs. GitHub's hardening guide recommends exactly this. A compromised step then holds a token that cannot push code or edit releases.

permissions:
  contents: read

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@<full-length-sha>

Put the top-level block in every workflow, even ones that only run tests. Raise permissions in the one job that publishes, not for the whole file.

How do I pin third-party actions safely?

Pin each action to a full-length commit SHA taken from the action's own repository, not from a fork. GitHub says a tag is acceptable only if you trust the author, because tags can be moved or deleted. Where you can use a lockfile for workflow dependencies, GitHub prefers it, because it records exact commits for every action including transitive ones.

Pinning has a cost. Dependabot alerts only cover semantically versioned actions, so it will not alert on actions pinned to SHAs. Enable Dependabot version updates to keep the references moving, and review each bump.

Also read the source of any action you add. Check that it does not send secrets or repository content to unexpected hosts or write them to logs.

How do I stop script injection in workflows?

Pass untrusted values through an intermediate environment variable instead of interpolating them into a run: script. Expressions inside run: are expanded before the shell sees them, so a pull request title can become a command.

Risky:

- run: echo "${{ github.event.pull_request.title }}"

Safer:

- env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: echo "$PR_TITLE"

GitHub also suggests using a JavaScript action that receives the value as an argument, and quoting shell variables to avoid word splitting.

Is pull_request_target safe to use?

Only if you never run untrusted pull request code inside it. GitHub advises avoiding pull_request_target unless you truly need its privileged context, and says workflow_run is generally the better choice for privilege separation. Never check out untrusted pull request code in workflows triggered by either, and treat artifacts from other workflows with caution.

How do I keep cloud credentials out of GitHub secrets?

Use OpenID Connect. With stored secrets you create a credential at the cloud provider and copy it into GitHub, where the workflow presents the same credential every run. With OIDC you set up trust once, and each job requests a short-lived token that is valid for a single job and then expires.

The cloud role holds conditions on claims such as repository, ref and environment. A condition like repo:octo-org/octo-repo:environment:prod restricts the role to one repository and environment. GitHub notes that custom OIDC claims are not supported on AWS.

How do I handle secrets that must stay in GitHub?

Store each sensitive value as its own secret and never in plaintext in workflow files. Redaction depends on exact matches, so a JSON or YAML blob in one secret is hard to mask. Register derived values, such as a Base64 encoded copy, as secrets too.

Gate production secrets with environments that require reviewers. If a secret reaches a log, delete the log and rotate the secret. Review secrets on a schedule and remove unused ones.

What about self-hosted runners?

Avoid them for public repositories, because anyone can open a pull request and try to compromise the runner. Even for private repositories, people with read access who can fork may reach them. Use ephemeral or just-in-time runners that start from a clean environment, keep sensitive data and network access to a minimum, and use runner groups to limit the blast radius.

How do I protect the workflow files themselves?

Add CODEOWNERS for .github/workflows so changes need a reviewer you trust. Combine it with branch protection that requires approvals and passing checks. Run the OpenSSF Scorecard action, which GitHub points to for flagging script injection, token permissions and unpinned actions.

Security checklist for a GitHub Actions workflow

  • Top-level permissions: contents: read present.
  • Every uses: pinned to a full-length SHA, with a comment naming the version.
  • No github.event expression inside a run: line.
  • No pull_request_target that checks out pull request code.
  • Cloud access through OIDC, not stored keys.
  • .github/workflows covered by CODEOWNERS.
  • Runners ephemeral where self-hosted.

To check what your workflows pull in, run the free Safeguard dependency scan on your manifest. It runs in the browser, needs no login, and keeps the file on your machine, up to 100 dependencies.

Frequently asked questions

Should I pin actions to a tag or a SHA?

Pin to a full-length commit SHA. GitHub says tags can be moved or deleted, so a tag is acceptable only if you trust the author.

Does pinning to a SHA stop Dependabot updates?

Dependabot version updates can still raise pull requests, but Dependabot alerts cover only semantically versioned actions. Monitor SHA-pinned actions yourself.

What is the safest default for GITHUB_TOKEN?

Read-only access to repository contents, with extra permissions granted per job.

Why is OIDC better than a stored cloud key?

Each job gets a token valid for a single job that then expires, so there is no long-lived credential in GitHub to leak.

Sources

Last reviewed: 19 October 2026.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.