GitHub Actions is convenient. It also handles secrets, deploys to clouds, and runs whatever code your .github/workflows/ files describe. A compromised Actions setup means a compromised production environment, often in ways that are silent until the breach is public.
Hardening GitHub Actions does not require new tools — it requires using the features that already exist. This post is the configuration patterns that turn a workflow from "anything can happen" to "controlled, audited, least-privilege."
The Threat Model
What can go wrong in a GitHub Actions setup:
- A malicious PR runs code on a runner with secrets attached
- A third-party action you reference is compromised in a later version
- A repository secret leaks through workflow logs
- A workflow uses long-lived AWS credentials that get exfiltrated
- A workflow can deploy to production from any branch
Each of these has a fix. None requires new infrastructure.
OIDC for Cloud Authentication
The biggest single improvement: replace long-lived cloud credentials with OIDC.
The traditional pattern stores AWS access keys as repository secrets. The keys live forever (until rotated). A workflow can use them; a compromised workflow can exfiltrate them.
The OIDC pattern: GitHub issues a short-lived token for each workflow run. AWS verifies the token via its OIDC provider and issues short-lived AWS credentials.
permissions:
id-token: write # required for OIDC
contents: read
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
- run: aws s3 cp ./build s3://mybucket/ --recursive
The AWS role's trust policy restricts who can assume it:
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:sub": "repo:yourorg/yourrepo:ref:refs/heads/main"
}
}
}
Only workflows from the main branch of yourorg/yourrepo can assume the role. A fork, a PR, or a different repo cannot.
Adopt OIDC for AWS, GCP, and Azure. The patterns are similar across providers. Long-lived keys in repository secrets are a 2020 pattern.
Permissions: Set Them Per Workflow
GitHub Actions has a permissions block at the workflow or job level. It controls what the workflow's GITHUB_TOKEN can do.
By default, the GITHUB_TOKEN often has broad read/write permissions. This is one of the most common Actions misconfigurations.
# Restrictive default
permissions: read-all
jobs:
build:
permissions:
contents: read # checkout
runs-on: ubuntu-latest
# ...
release:
permissions:
contents: write # tag and release
packages: write # publish to ghcr.io
runs-on: ubuntu-latest
# ...
Each job has only the permissions it needs. A compromised build step cannot push tags. A compromised release step cannot rewrite history.
Set the organization-wide default to read-all or read for the GITHUB_TOKEN. Override per workflow as needed. Most workflows do not need write permissions.
Pin Actions to SHAs
A common pattern:
- uses: actions/checkout@v4
The @v4 is a moving target. The maintainer can change what v4 points to. A compromised action update silently affects every workflow that references it.
The hardened pattern: pin to a specific commit SHA.
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
The SHA cannot change without a different SHA. Dependabot can update these for you, with diffs you can review before merging.
For your own organization's actions: SHAs are still recommended, even though you trust the publisher. The SHA captures exactly what version was running at the time of the workflow.
The major exception is internal "reusable workflows" called from your own repo at ref main — the trust boundary is the same.
Restrict Where Workflows Can Run From
By default, any repository in your organization can use any workflow. Restrict this.
Organization settings → Actions → "Allow specified actions and reusable workflows" lets you allow only specific GitHub-owned actions, your own, and a small allow-list of verified creators.
This prevents a malicious or compromised third-party action from being added to a workflow without explicit approval.
Environment Protection Rules
For production deploys, GitHub Actions has environment protection rules. An environment can require:
- Specific reviewers to approve before the workflow runs
- A wait timer before deployment proceeds
- Branch restrictions (only main can deploy to production)
- Required environment variables (forces explicit configuration)
jobs:
deploy-production:
environment: production # protected
runs-on: ubuntu-latest
# ...
The protection rules prevent a "deploy main to production" workflow from running automatically on every push. A human approves; the deploy proceeds.
Secret Scanning
GitHub Actions allows secrets to be referenced via ${{ secrets.NAME }}. The secrets are masked in logs — usually. The masking can be bypassed by:
- Echoing the secret with a transform (
echo ${SECRET:1}) - Logging the secret to a file that is uploaded as an artifact
- Sending the secret to a remote service via curl
Treat secret access as a privileged operation. Only the steps that need the secret should have access to it. Use env: scoping per step rather than per-job when possible.
The permissions block does not protect against secret exfiltration once the secret is in scope. The mitigation is to minimize the surface area — fewer steps with access to fewer secrets.
Fork PR Workflows
The most dangerous pattern: workflows that run automatically on PRs from forks. A malicious PR can include arbitrary workflow changes.
GitHub's defaults are relatively safe:
- Workflows from fork PRs run in a restricted context
- Secrets are not available
- The GITHUB_TOKEN is read-only
But teams often relax these for convenience. Common bad patterns:
pull_request_targetinstead ofpull_request— runs in the context of the target branch with secrets. A fork PR can now execute code with full repository access.- Custom checks that download and run untrusted code from the PR.
The safe rule: never give untrusted PR code access to secrets. Use pull_request (sandboxed). For deploys that need credentials, use a separate workflow that runs only on main.
Audit Logs
GitHub Audit Log shows every workflow run, secret access, permission change. For organizations with security teams, exporting this to a SIEM is valuable.
Things to alert on:
- Changes to organization secrets
- Permission changes on production-deploying repos
- New runners added (especially self-hosted)
- Actions allowed list changes
Most of these are infrequent enough that an alert per event is manageable.
Self-Hosted Runners
Self-hosted runners introduce new risks:
- A compromised job can persist data on the runner between jobs
- The runner has network access from your network
- Long-lived runners accumulate cached state
The mitigations were covered in a separate post on self-hosted runners. Briefly:
- Use ephemeral runners (destroyed per job)
- Never run untrusted code (forks, PR-triggered) on self-hosted runners
- Network-isolate runners from sensitive systems they do not need to reach
A Minimal Hardening Checklist
For an organization that wants to harden Actions over a week:
- Set organization-wide default
permissions: read-allfor GITHUB_TOKEN. - Allowlist actions at the organization level.
- Pin actions to SHAs in all workflows.
- Adopt OIDC for cloud authentication; rotate out long-lived keys.
- Add environment protection rules for production deploys.
- Audit secrets: any unused, any over-permissioned, any not needed.
- Review pull_request_target usage. Most should be
pull_request. - Stream audit log to SIEM if you have one.
A week of work prevents an entire category of incidents.
What This Does Not Cover
Hardening Actions reduces the supply chain attack surface. It does not address:
- Compromised developers (a developer with commit access can still cause damage)
- Bugs in dependencies (need software composition analysis)
- Issues in the application code itself
Defense in depth: hardened Actions is one layer, not the only one.
The Practical Outcome
A hardened GitHub Actions setup is mostly invisible. Workflows work the same as before. Permissions are more restrictive but rarely missing. OIDC replaces secrets in the right places. The audit trail is searchable.
The investment is one engineer for one week. The benefit is preventing the class of "compromised CI led to compromised production" incidents that have hit several major companies in recent years.
Reviewing GitHub Actions security and not sure where the gaps are? We help teams audit Actions setups and roll out OIDC, pinning, and protection rules without breaking active workflows. scopeforged.com