GitHub Actions Security Practices: 3 Key Protections Learned from the tj-actions Incident
In March 2025, a GitHub Advisory shattered my assumptions. tj-actions/changed-files—an Action I’d used for three years—was flagged as CVE-2025-30066. Over 23,000 repositories were suddenly exposed to secrets leakage risks overnight.
What sent chills down my spine was the attack method. The attackers first stole a PAT from a project, then tampered with the version tags of tj-actions, pointing previously safe code to a malicious script. Those secrets—AWS keys, database passwords, API tokens—quietly flowed into public logs.
Honestly, I was devastated. My own CI/CD pipeline had become an attacker’s stepping stone. I scrambled through all my repositories, checking Action version references, GITHUB_TOKEN permissions, and audit log settings. After a full day of scrambling, I realized how many details I had overlooked all along.
This article is the security checklist I compiled after that “close call.” Let’s discuss supply chain attack patterns, the right approach to secrets management, the details of GITHUB_TOKEN permission control, and the upcoming features in GitHub’s 2026 security roadmap worth preparing for now.
The key point: these are all things you can configure today—no need to wait for new features to launch.
Understanding CI/CD Supply Chain Attacks from the tj-actions Incident
How the Attack Happened
Let’s start with the tj-actions/changed-files Action. It’s popular on GitHub—used to detect which files changed in a PR, and many CI/CD pipelines rely on it. Several of my projects used it for incremental deployment decisions.
The attack chain went roughly like this:
The attackers first stole a PAT (Personal Access Token) from the reviewdog/action-setup project. This PAT had repository write permissions. With the token in hand, they used it to tamper with version tags in the tj-actions repository—quietly pointing the v45 tag, which previously referenced safe code, to a new commit with embedded malicious logic.
Projects still using uses: tj-actions/changed-files@v45 unknowingly pulled the malicious code. The malicious script did something simple: print all CI/CD environment secrets to the logs. Since logs were public, the secrets were leaked.
According to the GitHub Advisory report, over 23,000 repositories were affected. Coinbase’s security team later disclosed that the attack impacted data from more than 70,000 customers.
My Mistake: Tag References vs SHA Pinning
When checking my own repositories, I found many places using tag references:
# Wrong approach: using mutable tags
- name: Check changed files
uses: tj-actions/changed-files@v45
Tags are mutable. Repository maintainers (or attackers with write access) can point tags to new commits at any time. You think you’re referencing v45, but you’re actually running tampered malicious code.
The right approach is to pin with a full SHA:
# Correct approach: using full SHA
- name: Check changed files
uses: tj-actions/changed-files@6cbf527e7a7b6d61c4e7f25e5ce5f7b7c8f3c72a
SHA is immutable. As long as you don’t actively update the reference, you’re running that exact code—it never changes.
As I was fixing these, I was kicking myself: this is basic security常识, how did I never notice?
The Trust Cost of Third-Party Actions
The tj-actions incident also exposed another problem: our trust in third-party Actions is too cheap.
We grab any Action with enough stars and users and throw it into production CI/CD pipelines. But who knows about the maintainers’ security awareness? Who knows if their PATs might get stolen?
GitHub’s 2026 security roadmap mentions a solution: workflow-level dependency locking. Similar to package-lock.json, it locks all Action SHAs in a lockfile. You still write uses: tj-actions/changed-files@v45 in the workflow, but the lockfile records the corresponding SHA. When maintainers update the Action, the lockfile doesn’t change automatically—you need to actively review and update.
This feature isn’t live yet, but we can do it manually now—change each Action reference to SHA and audit regularly.
Another suggestion: reduce the number of third-party Actions. If an official Action can solve it, don’t use a third-party one. For file detection, you can actually use GitHub’s official actions/checkout with a shell script—no need to depend on tj-actions.
Three Levels of Secrets Management and Advanced Practices
GitHub Secrets’ Three Storage Tiers
GitHub’s built-in Secrets have three levels: organization-level, repository-level, and environment-level.
Organization-level Secrets can be shared across multiple repositories, suitable for storing common credentials like AWS keys and cloud service tokens. Repository-level Secrets are only available in the current repository, suitable for project-specific database passwords. Environment-level Secrets are more granular and can be paired with environment protection rules—for example, requiring PR approval before accessing production environment Secrets.
For storage, GitHub uses libsodium sealed box encryption. Once Secrets are written, they can’t be read back—they can only be accessed through the secrets context at workflow runtime:
steps:
- name: Deploy to AWS
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
A common mistake is interpolating Secrets directly into shell commands:
# Dangerous: Secrets might be printed to logs
- name: Configure AWS
run: aws configure set aws_access_key_id ${{ secrets.AWS_ACCESS_KEY_ID }}
If the Secrets values contain special characters or the command fails, the logs might expose the Secrets. The correct approach is to pass them via environment variables:
# Safe: passing via environment variables
- name: Configure AWS
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
run: aws configure set aws_access_key_id "$AWS_ACCESS_KEY_ID"
Dynamic Masking: ::add-mask::
Some sensitive data isn’t pre-stored Secrets but generated at runtime. For example, a temporary token output by a script. In these cases, you can use ::add-mask:: for dynamic masking:
- name: Generate temporary token
run: |
token=$(generate-token.sh)
echo "::add-mask::$token"
echo "TOKEN=$token" >> $GITHUB_ENV
Masked values appear as *** in logs. But note: masking must happen before the value is printed. If it’s printed first, then masked, it will still be exposed in the logs.
Advanced Solution: HashiCorp Vault OIDC Integration
If your project has high security requirements, using GitHub’s built-in Secrets might not be enough. If long-term credentials are stored in GitHub and the repository is compromised, all Secrets are lost.
A better solution is to use HashiCorp Vault with OIDC (OpenID Connect) for credential-less access. The principle is to let GitHub Actions prove “who I am” to Vault, and Vault verifies and issues a short-term token. This way, GitHub doesn’t store any long-term credentials.
The configuration steps are roughly:
Step 1: Configure OIDC Role in Vault
Vault needs to trust GitHub’s OIDC provider. Configure a role specifying which repositories can access which Secrets:
resource "vault_jwt_auth_backend_role" "github_actions" {
backend = "jwt"
role_name = "github-actions-role"
bound_audiences = ["https://github.com/your-org"]
user_claim = "repository"
role_type = "jwt"
token_policies = ["ci-policy"]
token_ttl = "1h"
}
Step 2: Use vault-action in GitHub Actions
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Import Secrets from Vault
uses: hashicorp/vault-action@v2.4.0
id: vault
with:
url: https://vault.example.com:8200
role: github-actions-role
method: jwt
secrets: |
secret/data/ci/aws accessKey | AWS_ACCESS_KEY_ID ;
secret/data/ci/aws secretKey | AWS_SECRET_ACCESS_KEY
- name: Deploy with AWS credentials
run: |
echo "Accessing AWS with Vault-provided credentials"
aws s3 ls
In this flow, vault-action automatically authenticates to Vault using the OIDC token provided by GitHub. After verification, Vault returns Secrets and injects them into environment variables. The token expires after 1 hour, and a new one is obtained on the next run.
Azure Key Vault also supports similar OIDC integration with a similar configuration, using the azure/login Action with Azure Key Vault Secrets.
GITHUB_TOKEN Permission Control in Practice
What is GITHUB_TOKEN?
Every GitHub Actions workflow run automatically receives a GITHUB_TOKEN. This is a temporary OAuth Token for operating on the current repository—for example, creating releases, pushing code, commenting on PRs.
The problem: GITHUB_TOKEN’s default permissions are too broad.
In older versions of GitHub Actions, GITHUB_TOKEN had almost full read-write permissions. Workflows could freely modify repository content, create branches, push code. If a workflow was exploited by an attacker (for example, through a malicious script triggered by a PR), GITHUB_TOKEN became the attacker’s weapon.
Complete Configuration of the permissions Key
GitHub introduced the permissions key in April 2021, letting you precisely control GITHUB_TOKEN’s permission scope.
The basic syntax is:
permissions:
actions: read|write|none # Manage Actions
contents: read|write|none # Repository content
issues: read|write|none # Issue operations
packages: read|write|none # GitHub Packages
pull-requests: read|write|none # PR operations
security-events: read|write|none # Security event reporting
deployments: read|write|none # Deployment status
statuses: read|write|none # Commit status
A key mechanism: once you add a permissions key in the workflow, all unspecified permissions automatically become none. This is the “least privilege” security boundary.
Workflow-Level vs Job-Level Permissions
permissions can be written at two levels: workflow-level (global) and job-level (local).
Workflow-level permissions apply to all jobs:
name: CI Pipeline
permissions:
contents: read # All jobs default to read-only repository content
issues: write # All jobs can write to Issues
jobs:
lint:
runs-on: ubuntu-latest
steps:
- run: echo "Lint with read-only access"
test:
runs-on: ubuntu-latest
steps:
- run: echo "Test with read-only access"
Job-level permissions can override workflow settings:
name: Release Pipeline
permissions:
contents: read # Default read-only
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read # Inherits read-only
steps:
- run: echo "Build needs read only"
release:
runs-on: ubuntu-latest
permissions:
contents: write # Override to write for creating Release
packages: write # Publish to Packages
steps:
- name: Create Release
uses: actions/create-release@v1
A real example: I have a project with a Release workflow where the build job only needs to read code, and the release job needs to create a release and push a Docker image. Through job-level permission isolation, even if the build job is attacked, it can’t write to repository content.
Repository-Level Permission Modes
Besides workflow configuration, GitHub repository settings also have two permission modes:
- Permissive: GITHUB_TOKEN defaults to read-write all permissions. Takes effect when workflows don’t specify
permissions. - Restricted: GITHUB_TOKEN defaults to read-only contents and packages. Workflows need to explicitly declare to get write permissions.
We recommend setting all repositories to Restricted mode. In repository Settings > Actions > General, find “Workflow permissions” and select “Read repository contents and packages permissions.”
This way, even if a workflow forgets to include permissions, it won’t have excessive privileges. The security boundary is locked at the repository level.
Audit Logs and Compliance Checks
Workflow Run Events in Audit Logs
Starting February 2021, GitHub included Actions workflow run events in organization audit logs. This means you can track who triggered which workflow, what permissions were used, and what secrets were accessed.
Audit log entry: Organization Settings > Security > Audit log.
Key fields include:
action: Event type, such asworkflow_run.create,workflow_run.completeactor: Trigger, could be username, App, orgithub-actions[bot]repo: Repository pathtoken_scopes: Token permission scope usedrequest_id: Request trace ID for log correlation
A practical use: trace the source when discovering abnormal workflow runs. For example, if a secret is accessed abnormally, find the trigger by searching workflow_run events.
Enterprise Audit API
If your organization uses GitHub Enterprise, you can query all operations with the Audit Log API:
curl -H "Authorization: Bearer YOUR_TOKEN" \
"https://api.github.com/enterprises/YOUR_ENTERPRISE/audit-log?phrase=workflow_run"
The returned JSON contains detailed event records. You can export to SIEM systems (like Splunk, Datadog) for continuous monitoring.
Compliance Requirements Brief
If your project needs to meet SOC 2 or ISO 27001 compliance, CI/CD security is a must. Audit logs are the most direct compliance evidence—proving you have the ability to track CI/CD activities, detect anomalies, and respond to incidents.
A suggested configuration: export audit logs to an external system and set alert rules for key events. For example:
- Sudden spike in workflow run failures
- Abnormal secrets access (large number of reads in short time)
- Workflows triggered by unfamiliar IPs
GitHub 2026 Security Roadmap Preview
In March 2026, GitHub released the Actions security roadmap, planning six major new features. Some are already live, some are still in development.
Workflow-Level Dependency Locking
This is the official solution for tj-actions-type attacks. Similar to package-lock.json, it locks all Action references to SHA. In the workflow, you still write uses: tj-actions/changed-files@v45, but the lockfile records the corresponding SHA. When maintainers update the Action, the lockfile doesn’t change automatically—you need to actively review and update.
This feature isn’t live yet, but we can manually do SHA pinning now.
Layer 7 Native Outbound Firewall
Native support for controlling external network access in CI/CD pipelines. For example, restricting workflows to only access your AWS API, not arbitrary external networks.
Currently, implementing this requires self-hosted runners + custom network policies. After the 2026 launch, GitHub cloud runners will also support outbound firewall configuration.
Scoped Secrets
More granular secrets scope control. For example, certain secrets can only be accessed by specific branches or specific jobs. Currently, secrets scope is repository-level or environment-level, not granular enough.
Policy-Driven Execution Control
Define trust boundaries, approvals, and attestation gates. For example, requiring all PRs from forks to pass manual approval before triggering workflows. Or requiring workflows to pass security scans before running.
This is similar to environment protection rules but with finer granularity and more complex logic.
Actions Data Stream
Real-time visibility into CI/CD activities. Similar to audit logs but pushed in real-time, not queried after the fact. Can be fed into SIEM systems for real-time monitoring.
OIDC Custom Attribute Claims
Enhanced cloud provider authentication. OIDC tokens can carry more custom attributes, like repository labels and branch information. Vault or AWS can make more granular authorization decisions based on these attributes.
Conclusion
From the tj-actions incident to now, the core lessons I’ve learned are three points:
First, SHA pinning. Don’t use tag references for third-party Actions—use full SHA. This is the lesson learned from 23,000 repositories.
Second, least privilege. GITHUB_TOKEN’s default permissions are too broad. Use the permissions key to restrict them and set repositories to Restricted mode.
Third, audit logs. Actions events are now in audit. Regularly check for abnormal runs and export to monitoring systems.
These three points can be configured right now. No need to wait for GitHub 2026 new features to launch, no need to switch to any new tools. Open your repositories, check Action references, permission settings, and audit logs. You can complete basic protection in half an hour.
Ultimately, CI/CD security isn’t advanced technology—it’s detail habits. Every additional SHA pin, every reduced excessive permission, reduces risk a little. The tj-actions incident taught us: attackers don’t need sophisticated methods—they just need us to overlook one small detail.
GitHub Actions Security Hardening Workflow
From SHA pinning to permission control, complete the three core steps for CI/CD security hardening.
⏱️ Estimated time: 30 min
- 1
Step1: Pin Action References with SHA
Check all workflow files and change `uses: action@tag` to `uses: action@full-sha` to avoid tag tampering. Use tools like `pinact` to batch convert existing references. - 2
Step2: Configure the permissions Key
Add the permissions key at the workflow or job level to grant only necessary privileges. For example: `permissions: contents: read` for read-only operations, `permissions: contents: write` for creating releases. Set the repository to Restricted mode as a fallback. - 3
Step3: Enable Audit Log Monitoring
View Actions workflow run events in organization Settings > Security > Audit log. Configure alerts for key events: sudden spikes in workflow run failures, abnormal secrets access, workflows triggered by unfamiliar IPs. Export logs to a SIEM system for continuous monitoring.
FAQ
Why use SHA pinning instead of tag references?
How do I control GITHUB_TOKEN permissions?
How can I detect if a workflow has been compromised?
What are the advantages of OIDC + Vault over built-in Secrets?
Should I use third-party Actions?
10 min read · Published on: May 16, 2026 · Modified on: May 17, 2026
GitHub Actions Complete Guide
If you landed here from search, the fastest way to build context is to jump to the previous or next post in this same series.
Previous
GitHub Actions Composite Action Development: Complete Guide from action.yml to Marketplace Publishing
Comprehensive guide to GitHub Actions composite action development, including action.yml structure, inputs/outputs configuration, secrets passing, version management strategies, and Marketplace publishing. Master CI/CD componentization best practices.
Part 8 of 9
Next
This is the latest post in the series so far.
Related Posts
GitHub Actions Matrix Build: Multi-Version Parallel Testing in Practice
GitHub Actions Matrix Build: Multi-Version Parallel Testing in Practice
GitHub Actions Getting Started: YAML Workflow Basics and Trigger Configuration
GitHub Actions Getting Started: YAML Workflow Basics and Trigger Configuration
GitHub Actions Getting Started: YAML Workflow Basics and Trigger Configuration
Comments
Sign in with GitHub to leave a comment