GitHub Actions workflows have access to secrets, cloud credentials, and deployment pipelines—making them high-value targets for attackers. A compromised workflow can leak credentials, deploy malicious code, or compromise your entire software supply chain. This guide covers essential security practices to harden your CI/CD pipelines.
Security Threat Model
┌─────────────────────────────────────────────────────────────────────────┐
│ GitHub Actions Security Threats │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ SUPPLY CHAIN CREDENTIAL THEFT CODE INJECTION │
│ ───────────────────────────────────────────────────────────────────── │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌────────────────┐ │
│ │ Compromised │ │ Secrets exposed │ │ Malicious PR │ │
│ │ Actions │ │ in logs │ │ code execution │ │
│ └─────────────────┘ └─────────────────┘ └────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ Pin actions to SHA Use OIDC, mask Validate inputs, │
│ Audit dependencies secrets, env vars restrict triggers │
│ │
│ PRIVILEGE ESCALATION PERSISTENCE LATERAL MOVEMENT │
│ ───────────────────────────────────────────────────────────────────── │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌────────────────┐ │
│ │ Excessive │ │ Compromised │ │ Cross-repo │ │
│ │ permissions │ │ runner/cache │ │ access │ │
│ └─────────────────┘ └─────────────────┘ └────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ Least privilege, Ephemeral runners, Scope tokens, │
│ scoped tokens isolated cache use GitHub Apps │
│ │
└─────────────────────────────────────────────────────────────────────────┘
OIDC Authentication
OIDC eliminates static credentials by using short-lived tokens verified by cloud providers.
Why OIDC Matters
| Traditional Secrets | OIDC Tokens |
|---|---|
| Long-lived credentials | Short-lived (minutes) |
| Stored in GitHub secrets | Generated per job |
| Can be leaked or stolen | Self-expiring |
| Manual rotation required | Automatic rotation |
| Same credentials everywhere | Scoped to workflow |
AWS OIDC Setup
1. Create IAM Identity Provider:
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
2. Create IAM Role:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789: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:org/repo:*"
}
}
}
]
}
3. Use in Workflow:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # Required for OIDC
contents: read
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/GitHubActionsRole
aws-region: us-east-1
# No access keys needed!
- name: Deploy
run: aws s3 sync ./dist s3://my-bucket/
Azure OIDC Setup
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Azure Login
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# Uses OIDC, no client secret needed
GCP OIDC Setup
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: 'projects/123456789/locations/global/workloadIdentityPools/github-pool/providers/github-provider'
service_account: '[email protected]'
- name: Deploy to Cloud Run
run: gcloud run deploy my-service --source .
Minimal Permissions
Always start with zero permissions and add only what's needed:
# Workflow-level: disable all permissions by default
permissions: {}
jobs:
lint:
runs-on: ubuntu-latest
permissions:
contents: read # Only read repository
steps:
- uses: actions/checkout@v4
- run: npm run lint
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # For OIDC
deployments: write # For deployment status
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh
comment-pr:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # For PR comments
steps:
- uses: actions/checkout@v4
- uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Build successful!'
})
Permission Reference
| Permission | Use Case |
|---|---|
contents: read | Checkout code |
contents: write | Push commits, create releases |
pull-requests: write | Comment on PRs, update status |
issues: write | Create/update issues |
id-token: write | OIDC authentication |
packages: write | Push to GitHub Packages |
deployments: write | Create deployment status |
security-events: write | Upload SARIF files |
Pin Actions to SHA
Version tags can be moved or compromised. Pin to commit SHA for supply chain security:
# INSECURE: Uses mutable tag
- uses: actions/checkout@v4
# SECURE: Uses immutable commit SHA
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
# Also pin reusable workflows
jobs:
build:
uses: org/repo/.github/workflows/build.yml@a1b2c3d4e5f6
Automating SHA Updates with Dependabot
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "chore(deps)"
Action Version Checker
# .github/workflows/check-actions.yml
name: Check Action Versions
on:
schedule:
- cron: '0 0 * * 1' # Weekly
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check for unpinned actions
run: |
grep -r "uses:" .github/workflows/ | \
grep -v "@[a-f0-9]\{40\}" | \
grep -v "\./" && \
echo "Found unpinned actions!" && exit 1 || \
echo "All actions pinned to SHA"
Protecting Secrets
Secure Secret Usage
jobs:
deploy:
runs-on: ubuntu-latest
steps:
# INSECURE: Secret visible in command
- run: curl -H "Authorization: Bearer ${{ secrets.API_KEY }}" https://api.example.com
# API_KEY might appear in logs!
# SECURE: Use environment variable
- run: curl -H "Authorization: Bearer $API_KEY" https://api.example.com
env:
API_KEY: ${{ secrets.API_KEY }}
# SECURE: Mask custom values
- name: Get token
id: token
run: |
TOKEN=$(./get-token.sh)
echo "::add-mask::$TOKEN"
echo "token=$TOKEN" >> $GITHUB_OUTPUT
- run: ./deploy.sh
env:
DEPLOY_TOKEN: ${{ steps.token.outputs.token }}
Environment Secrets
Use environments for production secrets with protection rules:
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
steps:
- run: ./deploy.sh
env:
API_KEY: ${{ secrets.API_KEY }} # Staging secret
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
- run: ./deploy.sh
env:
API_KEY: ${{ secrets.API_KEY }} # Production secret (different value)
Fork Security
Forks can be attack vectors. Handle them carefully:
Safe PR Handling
# DANGEROUS: pull_request_target runs with base repo secrets
on: pull_request_target
jobs:
dangerous:
runs-on: ubuntu-latest
steps:
# NEVER DO THIS: Checks out PR code with access to secrets
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: ./build.sh # PR code runs with secrets!
# SAFE: Separate approval workflow
on: pull_request
jobs:
safe:
runs-on: ubuntu-latest
steps:
# Safe: pull_request event doesn't have secrets for forks
- uses: actions/checkout@v4
- run: npm test
Require Approval for Fork PRs
# .github/workflows/approve-and-run.yml
name: Approve and Run
on:
pull_request_target:
types: [labeled]
jobs:
build:
if: github.event.label.name == 'safe-to-test'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm test
Input Validation
Validate all external inputs to prevent injection:
jobs:
comment:
runs-on: ubuntu-latest
steps:
# DANGEROUS: PR title directly in command
- run: echo "Processing: ${{ github.event.pull_request.title }}"
# Attacker can set title to: foo"; rm -rf / #
# SAFE: Use environment variable
- run: echo "Processing: $PR_TITLE"
env:
PR_TITLE: ${{ github.event.pull_request.title }}
# SAFE: Validate input
- name: Validate version
run: |
VERSION="${{ github.event.inputs.version }}"
if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Invalid version format"
exit 1
fi
Security Hardening Workflow
Complete example with all security practices:
name: Secure CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
# Disable all permissions by default
permissions: {}
jobs:
lint:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8
with:
node-version: '20'
- run: npm ci
- run: npm run lint
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8
with:
node-version: '20'
- run: npm ci
- run: npm test
deploy:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [lint, test]
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
environment:
name: production
url: https://app.example.com
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502
with:
role-to-assume: arn:aws:iam::123456789:role/GitHubActionsRole
aws-region: us-east-1
- name: Deploy
run: ./deploy.sh
env:
DEPLOY_ENV: production
Security Audit Checklist
┌─────────────────────────────────────────────────────────────────────────┐
│ GitHub Actions Security Checklist │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ AUTHENTICATION & AUTHORIZATION │
│ □ Using OIDC instead of static credentials for cloud providers │
│ □ Minimal permissions at workflow level (permissions: {}) │
│ □ Per-job permissions only granting what's needed │
│ □ Environment protection rules for production │
│ │
│ SUPPLY CHAIN │
│ □ All actions pinned to SHA (not version tags) │
│ □ Dependabot enabled for action updates │
│ □ Only using actions from trusted sources │
│ □ Reviewing action source before use │
│ │
│ SECRETS MANAGEMENT │
│ □ Secrets accessed via environment variables (not command args) │
│ □ Custom values masked with ::add-mask:: │
│ □ Separate secrets per environment │
│ □ No secrets exposed to fork PRs │
│ │
│ INPUT HANDLING │
│ □ External inputs validated before use │
│ □ No direct interpolation in shell commands │
│ □ PR content not used in pull_request_target │
│ │
│ MONITORING & AUDIT │
│ □ Workflow changes require review │
│ □ Audit logs monitored for suspicious activity │
│ □ Secret scanning enabled │
│ □ Code scanning (CodeQL) enabled │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Related Guides
- Git & GitHub Complete Guide - Overview of all Git topics
- GitHub Actions CI/CD - Build workflows
- Git Secrets Management - Protect credentials
- GitHub Repository Security - Harden repositories
Conclusion
GitHub Actions security requires a defense-in-depth approach:
- Use OIDC to eliminate static cloud credentials
- Pin actions to SHA to prevent supply chain attacks
- Apply minimal permissions at workflow and job level
- Protect secrets with environments and proper handling
- Validate inputs to prevent injection attacks
- Audit regularly for security issues
For a complete security approach, combine workflow security with repository security and secrets management.