Home/Blog/GitHub Actions Security: OIDC, Secrets, Permissions, and Supply Chain Protection
Software Engineering

GitHub Actions Security: OIDC, Secrets, Permissions, and Supply Chain Protection

Secure GitHub Actions workflows with OIDC authentication, minimal permissions, pinned actions, secret protection, fork security, and supply chain hardening best practices.

By Inventive HQ Team
GitHub Actions Security: OIDC, Secrets, Permissions, and Supply Chain Protection

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 SecretsOIDC Tokens
Long-lived credentialsShort-lived (minutes)
Stored in GitHub secretsGenerated per job
Can be leaked or stolenSelf-expiring
Manual rotation requiredAutomatic rotation
Same credentials everywhereScoped 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

PermissionUse Case
contents: readCheckout code
contents: writePush commits, create releases
pull-requests: writeComment on PRs, update status
issues: writeCreate/update issues
id-token: writeOIDC authentication
packages: writePush to GitHub Packages
deployments: writeCreate deployment status
security-events: writeUpload 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                                        │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Conclusion

GitHub Actions security requires a defense-in-depth approach:

  1. Use OIDC to eliminate static cloud credentials
  2. Pin actions to SHA to prevent supply chain attacks
  3. Apply minimal permissions at workflow and job level
  4. Protect secrets with environments and proper handling
  5. Validate inputs to prevent injection attacks
  6. Audit regularly for security issues

For a complete security approach, combine workflow security with repository security and secrets management.

Need Expert IT & Security Guidance?

Our team is ready to help protect and optimize your business technology infrastructure.