GitHub Actions security: the controls that actually reduce exposure
GitHub Actions security is unusual because the system checks out your source, holds your credentials, and executes code written by strangers, all in the same process. Six settings decide how much of that a compromised step can reach, and none of them is on by default.
A CI pipeline is the softest target in most engineering organizations precisely because it is treated as plumbing. It has a token for your repository, secrets for your registries and cloud accounts, and it runs third-party code on every commit. Nobody reviews it the way they review the application it builds.
The controls below are unglamorous and specific: six decisions about permissions, provenance, triggers, identity, input handling and isolation. Every claim here is from docs.github.com read on 2026-09-20, and the hardened workflow at the end puts all six in one file.

Scope the token before anything else
Every job gets a GITHUB_TOKEN, created automatically at the start of the job and scoped to the repository holding the workflow. A compromised step inherits whatever that token can do, so it is the shortest path from "a dependency was backdoored" to "a commit was pushed to main".
GitHub states the practice plainly: "It's good security practice to set the default permission for the GITHUB_TOKEN to read access only for repository contents. The permissions can then be increased, as required, for individual jobs." The mechanism is a permissions block, at the workflow level for the default and at the job level where a write is genuinely needed. Sixteen scopes exist, most taking read, write or none, and permissions: {} removes all of them. Two are narrower than that: id-token is write or none, because fetching an OIDC token is not a read, and vulnerability-alerts is read or none, because write is not valid for it.
One detail catches people: once you specify any permission, every scope you did not name is set to none. That is the behavior you want, and a job that starts failing after you add the block is telling you what it was quietly using.
permissions:
contents: read # the default for every job in this workflow
jobs:
test:
runs-on: ubuntu-24.04
# inherits contents: read, everything else none
release:
runs-on: ubuntu-24.04
permissions:
contents: write # only this job can push a tag
id-token: write # and only this job can mint an OIDC tokenPin every action you do not own
A tag is mutable. uses: some/action@v3 resolves to whatever that tag points at the moment your job starts, so trusting a tag means trusting the maintainer, everyone with write access to that repository, and anyone who compromises either of them, indefinitely and retroactively. GitHub is unambiguous about the alternative: "Pinning an action to a full-length commit SHA is currently the only way to use an action as an immutable release."
The cost is a comment and a Dependabot rule. Keep the human-readable version in a trailing comment so review stays possible, and let Dependabot bump the pins the way it bumps package versions, which turns this into review time rather than maintenance time. The pins below were read from the GitHub API on 2026-09-20 and are the current majors: actions/checkout v7, actions/cache v6 and actions/setup-node v7.
# Mutable: whatever v7 points at when the job starts
- uses: actions/checkout@v7
# Immutable: the exact tree you reviewed
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0pull_request_target is the one that gets exploited
The pull_request trigger runs fork code with no secrets and a read-only token, which is the safe default and the reason most projects never think about this. pull_request_target is different: GitHub documents it as running "in the context of the default branch of the base repository, rather than in the context of the merge commit", which gives it access to secrets and a write-capable token.
It exists to label or comment on fork pull requests, where the workflow needs permission and not the contributor code. The exploit is combining it with a checkout of the pull request head, which executes untrusted code in a context holding your secrets. GitHub warns that "running untrusted code on the pull_request_target trigger may lead to security vulnerabilities. These vulnerabilities include cache poisoning and granting unintended access to write privileges or secrets", and the security reference states the rule directly: workflows using these triggers "must not explicitly check out untrusted code, including from pull request forks".
If you need to build fork code, use pull_request and accept that it has no secrets, or split the work across two workflows: an unprivileged one that builds and uploads an artifact, and a privileged one on workflow_run that consumes it without executing it.
What a fork can and cannot see
The rule is one sentence in the docs and worth memorizing: "With the exception of GITHUB_TOKEN, secrets are not passed to the runner when a workflow is triggered from a forked repository." A contributor opening a fork pull request cannot print your registry password, because it is not on the machine.
That protection has edges. It does not apply to pull_request_target, which is the point of the section above, and it does not stop a contributor reading anything your workflow itself fetches and logs. A workflow that needs secrets to validate a fork contribution is a design problem rather than a configuration one: approve the run, or move the privileged half out of the untrusted context.
OIDC instead of a stored cloud key
A stored cloud access key is a credential with no expiry sitting somewhere many people can reach. OpenID Connect replaces it: the docs describe it as letting "your workflows exchange short-lived tokens directly from your cloud provider", so the provider "issues a short-lived access token that is only valid for a single job, and then automatically expires".
Mechanically the workflow asks for an identity token, which requires id-token: write on that job and nothing else, and hands it to the cloud provider in exchange for credentials. The trust relationship is configured on the cloud side against the token subject claim, which encodes the repository and often the environment: repo:octo-org/octo-repo:environment:prod is the example in GitHub's own documentation. That claim is where the scoping lives, so a trust policy accepting any branch of any repository in your organization has given away most of the benefit. This is the only control here that removes a credential rather than narrowing one.
jobs:
deploy:
runs-on: ubuntu-24.04
permissions:
contents: read
id-token: write
steps:
- uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: arn:aws:iam::111122223333:role/github-deploy
aws-region: us-east-1Untrusted input, and where the job runs
Two smaller controls close the remaining gaps. The first is script injection: a pull request title interpolated straight into a run: block is shell code the contributor gets to write. GitHub's recommendation is to route it through a variable, because "the preferred approach to handling untrusted input is to set the value of the expression to an intermediate environment variable". Branch names, commit messages and issue bodies are the same.
The second is the machine. A persistent shared runner keeps a checkout of your source and whatever the previous job left on disk, so one compromised job can reach the next. GitHub puts it bluntly: "self-hosted runners should almost never be used for public repositories on GitHub", because "any user can open pull requests against the repository and compromise the environment". An ephemeral runner that takes one job and is then destroyed removes that pivot, which is how the Latchkey runners overview describes its fleet.
# Injectable: the title becomes part of the command
- run: echo "Reviewing ${{ github.event.pull_request.title }}"
# Safe: the shell reads a variable, it does not parse the value
- env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "Reviewing $PR_TITLE"All six in one workflow
Nothing here is exotic: a read-only default, SHA pins with readable comments, the safe trigger, OIDC instead of a stored key, no untrusted interpolation. Copy it and delete what you do not use.
name: ci
on:
pull_request: # fork code, no secrets, read-only token
push:
branches: [main]
permissions:
contents: read # read-only default for every job
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
- env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "tested: $PR_TITLE"
publish:
needs: test
if: github.event_name == 'push'
runs-on: ubuntu-24.04
permissions:
contents: read
id-token: write # OIDC, only on the job that deploys
packages: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: arn:aws:iam::111122223333:role/github-deploy
aws-region: us-east-1
- run: ./scripts/publish.shKey takeaways
- Set
permissions: contents: readat workflow level, then grant writes on the one job that needs them. - A full commit SHA is the only immutable way to use a third-party action.
pull_request_targetplus a checkout of the pull request head is the misconfiguration that gets exploited.- Fork pull requests get no secrets except
GITHUB_TOKEN, and that protection ends atpull_request_target. - OIDC removes the credential instead of narrowing it, which is why it is the biggest single win.
Frequently asked questions
Why pin GitHub Actions to a commit SHA instead of tags?
@v3 resolves at job start, so the code you reviewed and the code that runs are related only by the maintainer choosing not to move the tag, and by nobody else with write access choosing to move it either. GitHub describes a full-length commit SHA as the only way to use an action as an immutable release.How does GitHub Actions OIDC work with AWS?
id-token: write, and exchanges it with AWS STS for temporary credentials. AWS validates the token against an IAM role whose trust policy matches the subject claim, which names your repository and optionally the environment. No access key is ever stored, and the credentials expire when the job ends.What permissions does GITHUB_TOKEN need?
contents: read and nothing else. Actions rarely document their requirements well, which is what actions/cache#1608 asks for, so the method is to set a read-only default, run the pipeline, and grant exactly the scope each failure names.What is the difference between pull_request and pull_request_target?
pull_request runs the merge commit with no secrets and a read-only token, so fork code is safe to execute. pull_request_target runs against the base repository default branch with secrets and a write-capable token, so it is safe only while it does not touch contributor code. The moment it checks out the pull request head, a stranger code runs with your credentials in scope.Related guides
References
- GitHub Actions security reference: secure use (read 2026-09-20)
- Workflow syntax: the GITHUB_TOKEN permission scopes (read 2026-09-20)
- About security hardening with OpenID Connect (read 2026-09-20)
- Using secrets: secrets are not passed to forked repositories (read 2026-09-20)
- Events that trigger workflows: pull_request_target (read 2026-09-20)
- GitHub Actions documentation