Originally published on devopsstart.com, this guide explores how to eliminate static secrets and harden your GitHub Actions pipelines against credential theft.
Introduction
The fastest way to compromise a production environment isn't by hacking a firewall; it's by stealing a long-lived AWS Access Key leaked in a GitHub Actions log. Secret leakage in CI/CD pipelines is a systemic risk because these pipelines possess the "keys to the kingdom", allowing them to provision infrastructure, modify databases and push code to production.
When secrets leak, they typically happen through three vectors: accidental logging, compromised third-party actions or malicious pull requests from external contributors. To stop this, you must move from static secrets to identity-based authentication using OpenID Connect (OIDC) and implement a strict least-privilege model for your workflow permissions.
In this guide, you will learn how to implement OIDC, the danger of mutable version tags, and how to defend against "pwn-request" attacks. For those managing complex infrastructure, combining these security practices with how to automate terraform reviews with github actions ensures that security is baked into the code review process, not just the execution phase.
The Anatomy of a Secret Leak: Why Your Logs Aren't Safe
GitHub provides a built-in masking feature that replaces known secrets with asterisks (***) in the logs. However, this is a convenience feature, not a security boundary. Attackers can easily bypass masking by encoding the secret. If a developer runs echo $SECRET | base64, the resulting string is no longer the original secret and will not be masked. Any user with read access to the action run can decode it instantly.
Another common leak vector is the "debug dump". When a pipeline fails, developers often add run: env or run: printenv to debug the environment. This prints every single environment variable to the logs. While GitHub tries to mask the secrets, any variable that was dynamically generated or slightly modified during the build process will leak in plain text.
The most dangerous leak comes from the supply chain. If you use a third-party action like uses: some-random-user/setup-tool@v1, you are executing arbitrary code from that user's repository. If that account is compromised, the attacker can update the code in @v1 to curl your environment variables to an external server. Because the action runs with the GITHUB_TOKEN and any secrets you passed to it, the attacker gains full access without leaving a trace in your logs.
Moving from Static Secrets to OIDC
The industry standard for securing cloud access in CI/CD is OpenID Connect (OIDC). Long-lived IAM keys (the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY pair) are liabilities because they never expire and are often stored as static GitHub Secrets. If these leak, they remain valid until you manually rotate them. OIDC replaces these static keys with short-lived, identity-based tokens.
With OIDC, GitHub Actions acts as an Identity Provider (IdP). When a workflow runs, it requests a JWT (JSON Web Token) from GitHub. The workflow then presents this token to the cloud provider (AWS, Azure or GCP). The cloud provider verifies the token's signature and checks if the "claims" (such as the repository name or the branch) match a pre-defined trust relationship. If they match, the provider issues a temporary security token, typically valid for one hour.
To implement this in AWS, you first create an IAM Role with a Trust Policy that trusts the GitHub OIDC provider. Then, use the official aws-actions/configure-aws-credentials action (v4). You must specify permissions: id-token: write in your YAML to allow the runner to request the JWT.
# Example: OIDC Authentication for AWS
name: Secure Deploy
on:
push:
branches: [ main ]
permissions:
id-token: write # Required for requesting the JWT
contents: read # Required for checkout
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-oidc-role
aws-region: us-east-1
- name: Verify Identity
run: aws sts get-caller-identity
The output of the last command shows the assumed role, not a static user. If this workflow is compromised, the attacker only has a temporary token that expires quickly, which reduces the blast radius significantly compared to static keys.
Hardening the Supply Chain: The Danger of Mutable Tags
Most DevOps engineers use version tags when referencing actions, such as uses: actions/checkout@v4. This looks clean, but it is a security anti-pattern. Tags in Git are mutable; a maintainer (or an attacker who has hijacked the account) can move the v4 tag to a different, malicious commit. You think you are using a trusted version, but the underlying code has changed without your knowledge.
To eliminate this risk, pin actions to a full-length commit SHA. A SHA is an immutable fingerprint of the code. If the code changes by a single character, the SHA changes. While this makes updating actions more tedious, it is the only way to guarantee that the code you audited is the code running today.
I have seen this fail in clusters with >50 nodes where a single compromised community action allowed an attacker to exfiltrate internal environment variables across dozens of repos. In a production environment with over 100 repositories, manually updating SHAs is a burden. Use a tool like Renovate Bot or Dependabot to automate these updates while keeping them pinned.
# UNSAFE: Using a mutable tag
# If the maintainer changes what @v4 points to, your pipeline is compromised.
- uses: actions/checkout@v4
# SAFE: Using a full-length commit SHA
# This code will NEVER change, regardless of what happens to the repository tags.
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
When pinning, always include a comment noting which version the SHA corresponds to. In clusters where security compliance is strict, such as those running on GKE Autopilot or hardened EKS nodes, this level of granularity is mandatory to pass SOC2 or ISO27001 audits.
Defending Against "Pwn-Requests" and Fork Attacks
One of the most overlooked vulnerabilities in GitHub Actions is the handling of Pull Requests from forks. By default, the pull_request event does not grant secrets to the runner for security reasons. However, developers often find this frustrating when they need to run integration tests that require a database key. To solve this, they use the pull_request_target event.
The pull_request_target event is extremely dangerous. Unlike pull_request, it runs in the context of the base branch (usually main) and has access to secrets. If you have a workflow triggered by pull_request_target that checks out the code from the PR branch and then runs a script, a malicious contributor can modify that script in their fork to echo $SECRET | base64. Since the workflow runs with the base branch's permissions, the attacker steals your production credentials.
To safely handle external contributions, never execute untrusted code from a fork while secrets are present. If you need to run tests on a PR, use the standard pull_request event and utilize "Environment" protections.
# DANGEROUS: Vulnerable to pwn-requests
on:
pull_request_target:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # This checks out the PR code from the fork
- run: npm install && npm test # The PR author can change 'npm test' to steal secrets
env:
API_KEY: ${{ secrets.API_KEY }}
The correct pattern is to require a manual approval from a maintainer before a workflow can access a protected environment's secrets. This creates a human-in-the-loop firewall that prevents automated credential theft.
Best Practices for CI/CD Hardening
To maintain a secure posture, implement these five practices across every repository in your organization.
-
Implement a Global Permissions Policy: Start every job with the most restrictive permissions. Use
permissions: contents: readby default and only addid-token: writeorpackages: writewhen specifically required. This prevents a compromised action from deleting your repository. - Use Environment-Based Secrets: Do not put production secrets in the global "Repository Secrets" section. Create a "Production" environment and assign secrets there. This allows you to enforce "Required Reviewers", meaning no code can access production keys without a senior engineer's sign-off.
-
Automate Secret Scanning: Integrate Gitleaks or TruffleHog into your pipeline as a pre-commit hook or an initial CI step. These tools look for patterns (like
AKIA...for AWS) and fail the build if a secret is detected in the commit history. - Avoid Secret Passing via Env: Instead of passing secrets as environment variables to every step, pass them only to the specific step that needs them. This minimizes the number of processes that have the secret in their memory space.
- Rotate Credentials Every 90 Days: Even with OIDC, some legacy systems require static keys. Implement a strict rotation policy. If a key is not rotated regularly, a leak might go undetected for months, giving attackers a permanent backdoor.
FAQ
Does GitHub really mask all my secrets in the logs?
No. GitHub only masks the exact string stored in the secret. If your code transforms the secret (e.g., base64 encoding, URL encoding or splitting the string), the resulting output will not be masked. Never rely on masking as a primary security control.
Why is pull_request_target worse than pull_request?
pull_request runs in the context of the merge commit and has no access to secrets from the base repository. pull_request_target runs in the context of the base branch and has full access to secrets, meaning any code introduced by a contributor in a fork can access those secrets if the workflow executes that code.
Should I use OIDC for every single cloud provider?
Yes. Every major provider (AWS, Azure, GCP and HashiCorp Vault) now supports OIDC for GitHub Actions. Moving away from static JSON keys or CSV credential files reduces your operational overhead and eliminates the risk of "stale" credentials living in your repository settings.
Can I still use version tags like @v4 if I use a private runner?
Yes, but it is still a bad practice. Even on a private runner, a compromised third-party action can exfiltrate data from your internal network or steal the GITHUB_TOKEN to modify your source code. The location of the runner does not protect you from supply chain attacks.
Conclusion
Securing GitHub Actions requires moving away from the "trust by default" mindset. The combination of OIDC for identity, SHA pinning for supply chain integrity and strict permissions blocks creates a defense-in-depth strategy. The most critical immediate step you can take is auditing your workflows for pull_request_target and replacing static cloud keys with OIDC roles.
Start by implementing these three actionable steps today: first, replace all v* tags with commit SHAs in your most critical deployment pipeline. Second, migrate your production cloud authentication to OIDC to eliminate long-lived keys. Third, configure GitHub Environments with mandatory reviewers for all production secrets. By shifting security left into your CI/CD configuration, you ensure that your pipeline is a tool for delivery, not a liability.
Top comments (0)