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.

Frequently Asked Questions

Find answers to common questions

OIDC (OpenID Connect) allows GitHub Actions to authenticate with cloud providers without storing long-lived credentials. GitHub generates a short-lived JWT token that cloud providers (AWS, Azure, GCP) verify. This eliminates the need for static access keys stored as secrets, reducing security risk significantly.

Pin actions to specific commit SHAs instead of version tags. Change uses: actions/checkout@v4 to uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11. SHA pinning ensures you always use the exact same code, preventing attackers from compromising action versions. Use Dependabot to update pinned SHAs safely.

Follow least-privilege principle. Set permissions: {} at workflow level to disable all permissions, then grant only what's needed per job. Common permissions include contents: read for checkout, pull-requests: write for PR comments, id-token: write for OIDC. Never use permissions: write-all in production.

Never echo secrets or use them in command arguments visible in logs. Use environment variables instead of command-line parameters. Mask custom values with echo "::add-mask::$VALUE". Use OIDC instead of static credentials. Review workflow runs for exposed values. Enable secret scanning and push protection.

GITHUB_TOKEN is an automatically generated token for GitHub API access. It's scoped to the repository and expires after the job. Use it instead of PATs when possible. Set minimal permissions for the token. Be aware it can't access other private repos. For cross-repo access, use a GitHub App instead of PATs.

Forks can run workflows and potentially access secrets. Use pull_request_target carefully—it runs with base repo context. Never checkout PR code in pull_request_target. Use environments with protection rules. Require approval for first-time contributors. Avoid exposing secrets to fork PRs.

Self-hosted runners give you control but require security hardening. Never use self-hosted runners for public repos—malicious PRs could execute code on your infrastructure. For private repos, use ephemeral runners, network isolation, and minimal permissions. GitHub-hosted runners are often more secure for public repos.

Review workflow files for hardcoded secrets, unpinned actions, and excessive permissions. Check audit logs for unexpected workflow runs. Use GitHub's security features like code scanning and secret scanning. Implement required reviews for workflow changes. Use tools like actionlint and step-security/harden-runner.

Use OIDC for cloud authentication, pin actions to SHA, set minimal permissions, use environments with protection rules, never expose secrets to forks, validate inputs from external sources, use actions only from trusted sources, enable branch protection, and regularly audit workflows.

Only use actions from verified creators or trusted sources. Review action source code before use. Pin to SHA instead of tags. Fork actions into your org for critical workflows. Use Dependabot to track updates. Consider creating internal actions for sensitive operations instead of using third-party code.

Engineering Excellence for Your Business

Our engineers build systems that scale. Clean architecture, comprehensive testing, and security-first development.