Back to Blog
critical SEVERITY7 min read

How Credential Leakage in GitHub Actions Happens in Node.js and How to Fix It

A GitHub Actions workflow in Node.js was storing authentication tokens in plain variables without masking them in logs, creating a critical security risk. When debug mode was enabled or errors occurred, tokens could be exposed in console output and GitHub Actions logs. The fix uses the `setSecret()` API to automatically mask sensitive credentials throughout the execution.

O
By Orbis AppSec
Published August 6, 2026Reviewed August 6, 2026

Answer Summary

This is a Credential Leakage vulnerability (CWE-532) in a Node.js GitHub Actions workflow where authentication tokens were stored without masking, allowing exposure through debug logs and error stack traces. The fix applies `setSecret(token)` immediately after token retrieval to ensure GitHub Actions automatically masks the value in all logs, preventing accidental disclosure to anyone with read access to workflow logs.

Vulnerability at a Glance

cweCWE-532 (Insertion of Sensitive Information into Log File)
fixCall `setSecret(token)` immediately after token retrieval
riskGitHub authentication tokens exposed in publicly readable workflow logs
languageJavaScript/Node.js
root causeTokens retrieved but never registered with GitHub Actions masking system
vulnerabilityCredential Leakage via Unmasked Secrets in Debug Logs

How Credential Leakage in GitHub Actions Happens in Node.js and How to Fix It

The Incident

In the action.js file of a Node.js GitHub Actions workflow, we discovered a critical credential leakage vulnerability where GitHub authentication tokens were being stored in plain variables without any masking protection. When debug mode was enabled via the ACTIONS_STEP_DEBUG secret or when errors occurred, the token values could be exposed in console output and permanently stored in GitHub Actions logs—accessible to anyone with read access to the repository.

The vulnerability was particularly dangerous because:
- Line 8 retrieved the token: const token = getInput('token', {required: true})
- No masking was applied to this sensitive credential
- Error stack traces printed via console.error(error.stack) could include the token value
- Debug logs enabled by ACTIONS_STEP_DEBUG would expose the entire execution context

This created an attack vector where a malicious actor could trigger an error condition, enable debug logging, and extract the token from the published logs.


The Vulnerability Explained

What Went Wrong

The original code retrieved a GitHub authentication token at line 8 but never registered it with GitHub Actions' masking system:

// VULNERABLE CODE - before the fix
import {getInput, isDebug, setFailed, setOutput, info} from '@actions/core'

;(async () => {
  try {
    const token = getInput('token', {required: true})
    // ❌ Token is now in memory but NOT masked in logs
    const enterprise = getInput('enterprise', {required: false}) || null
    // ... rest of code

The problem stems from a misunderstanding of how GitHub Actions logging works. By default, GitHub Actions does not automatically mask arbitrary variables—you must explicitly register them using the setSecret() API.

The Attack Scenario

Here's how an attacker could exploit this:

  1. Attacker triggers the action with a malicious input designed to cause an error in the policy loading logic
  2. Attacker enables debug mode by setting the ACTIONS_STEP_DEBUG secret to true in the repository
  3. Debug logs are generated showing the full execution context, including the token variable value
  4. Error stack trace prints via console.error(error.stack) at line 56, potentially including the token in the error context
  5. Token is exposed in the public GitHub Actions logs, which any repository collaborator can read
  6. Attacker uses the token to make authenticated API calls to GitHub, potentially accessing private repositories, modifying organization settings, or compromising infrastructure

The vulnerability is particularly insidious because:
- It's invisible by default—the code looks normal
- It only manifests when debug mode is enabled (which developers often use during troubleshooting)
- The exposure is permanent in GitHub's log storage
- The token is high-value—GitHub tokens grant broad API access

Real-World Impact

For this specific action, the compromised token could allow an attacker to:
- Modify GitHub Actions allow lists across the entire organization
- Access enterprise-level policy settings
- Potentially pivot to other systems authenticated via the same token
- Conduct supply chain attacks through modified action policies


The Fix

What Changed

The fix involves two critical changes to action.js:

Change 1: Import the setSecret function

// BEFORE
import {getInput, isDebug, setFailed, setOutput, info} from '@actions/core'

// AFTER
import {getInput, isDebug, setFailed, setOutput, setSecret, info} from '@actions/core'

Change 2: Mask the token immediately after retrieval

// BEFORE
;(async () => {
  try {
    const token = getInput('token', {required: true})
    const enterprise = getInput('enterprise', {required: false}) || null

// AFTER
;(async () => {
  try {
    const token = getInput('token', {required: true})
    setSecret(token)  // ✅ Register token with GitHub's masking system
    const enterprise = getInput('enterprise', {required: false}) || null

This single line—setSecret(token)—tells GitHub Actions to automatically replace all occurrences of the token value with *** in all logs, both in real-time and in the permanent log storage.

Why This Works

When you call setSecret(token):

  1. GitHub Actions registers the token value in an internal masking list
  2. All subsequent output (via console.log(), console.error(), info(), etc.) is scanned
  3. Any occurrence of the token value is replaced with *** before being written to logs
  4. The masking is irreversible—even if the token appears in error messages or stack traces, it's automatically redacted
  5. The protection applies globally—not just to specific log statements

Additional Hardening: String Concatenation

The PR also made a subtle but important change to prevent tokens from leaking through template literals:

// BEFORE - Template literals can expose values in certain contexts
info(`✅ Loaded Existing GitHub Actions allow list for ${enterprise || organization}`)

// AFTER - String concatenation is more explicit and reduces accidental exposure
info('✅ Loaded Existing GitHub Actions allow list for ' + (enterprise || organization))

While this change alone doesn't prevent the vulnerability (since enterprise and organization are not secrets), it demonstrates defense-in-depth by reducing the use of template literals, which can sometimes bypass masking in edge cases.


Prevention & Best Practices

For GitHub Actions in Node.js

  1. Always mask sensitive inputs immediately
    javascript const apiKey = getInput('api_key', {required: true}) setSecret(apiKey) // Do this first, before any other operations

  2. Use @actions/core for all sensitive operations
    - setSecret() for credentials
    - info() instead of console.log() for consistency
    - setFailed() for error handling that respects masking

  3. Never concatenate secrets into error messages
    ``javascript // ❌ BAD - Token might appear if API returns it in error try { await api.call(token) } catch (error) { throw new Error(API call failed: ${error.message}`)
    }

// ✅ GOOD - Log error without secret context
try {
await api.call(token)
} catch (error) {
throw new Error('API call failed: authentication error')
}
```

  1. Test with debug mode enabled
    - Set ACTIONS_STEP_DEBUG: true in your workflow
    - Verify that sensitive values show as *** in the logs
    - Check error messages don't leak credentials

  2. Use Semgrep to detect unmasked secrets
    bash semgrep --config=p/security-audit --config=p/github-actions .

CWE and OWASP References

  • CWE-532: Insertion of Sensitive Information into Log File
  • CWE-798: Use of Hard-Coded Credentials
  • OWASP A09:2021: Security Logging and Monitoring Failures

The vulnerability falls under the broader category of Sensitive Data Exposure and relates to improper logging practices.


Key Takeaways

  • Never retrieve credentials without immediately masking them: The setSecret() call must happen on the very next line after credential retrieval in GitHub Actions
  • Debug mode is a liability: Always assume ACTIONS_STEP_DEBUG could be enabled by a developer, and ensure your code is safe under that condition
  • Template literals don't prevent masking, but string concatenation is more explicit: While GitHub's masking works with template literals, explicit string concatenation reduces cognitive load and makes the intent clearer
  • Error handling is a common leak vector: Even if your main code is secure, error messages and stack traces can expose secrets—always sanitize error output
  • The fix is a single line but has massive impact: Adding setSecret(token) costs nothing but prevents the entire attack surface for credential leakage

How Orbis AppSec Detected This

Source: The token input parameter retrieved via getInput('token', {required: true}) at line 8 of action.js

Sink: The unmasked token variable used throughout the function, particularly in error handling via console.error(error.stack) at line 56 and potential inclusion in log messages

Missing control: The setSecret() function from @actions/core was not called on the token after retrieval, leaving it unmasked in logs and debug output

CWE: CWE-532 (Insertion of Sensitive Information into Log File)

Fix: Added setSecret(token) immediately after token retrieval (line 9) and imported the setSecret function from @actions/core (line 2)

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.


Conclusion

Credential leakage through debug logs is a silent but critical vulnerability in GitHub Actions workflows. The fix—calling setSecret() on sensitive credentials—is trivial to implement but transformative in security impact.

The key lesson is that security is a series of deliberate actions, not automatic defaults. GitHub Actions provides powerful masking capabilities, but they only work when developers explicitly register their secrets. By making setSecret() a reflexive habit—called immediately after every credential retrieval—you eliminate an entire class of vulnerabilities.

This vulnerability affected production code used by downstream consumers of this package, making the fix essential. If you maintain GitHub Actions workflows or libraries, audit your code today for unmasked credentials and apply this pattern consistently across all sensitive inputs.


References

Frequently Asked Questions

What is Credential Leakage in GitHub Actions?

It occurs when sensitive credentials (tokens, passwords, API keys) are stored in variables but not masked, allowing them to appear in logs when debug mode is enabled or errors occur.

How do you prevent credential leakage in Node.js GitHub Actions?

Always call `setSecret()` from `@actions/core` immediately after retrieving sensitive credentials to register them with GitHub's automatic masking system.

What CWE is credential leakage?

CWE-532 (Insertion of Sensitive Information into Log File), which covers any sensitive data written to logs without proper redaction.

Is using environment variables enough to prevent credential leakage?

No. While environment variables are better than hardcoding, they still appear in logs unless explicitly masked using `setSecret()` in GitHub Actions.

Can static analysis detect unmasked secrets?

Yes. Tools like Semgrep and Orbis AppSec can detect patterns where credentials are retrieved but `setSecret()` is never called on them.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #186

Related Articles

high

How Dependabot Missing Cooldown Periods Happen in GitHub Actions and How to Fix It

A missing cooldown period in Dependabot configuration creates a supply chain vulnerability by allowing automatic updates to newly published packages that could be malicious or unstable. This fix adds a 7-day cooldown to the `.github/dependabot.yml` file, ensuring newly published package versions are vetted before being proposed for update. This is critical for Node.js libraries where vulnerabilities affect all downstream consumers.

high

How missing cooldown periods in Dependabot configuration happen in GitHub Actions and how to fix it

A high-severity vulnerability was discovered in a Node.js library's `.github/dependabot.yml` configuration file where no cooldown period was set for package updates. This exposed the project to potentially malicious or unstable newly-published packages, as Dependabot would immediately propose updates without any waiting period. The fix adds a 7-day cooldown to the npm package-ecosystem configuration, ensuring a safety window before adopting new package versions.

high

How secrets: inherit over-privilege happens in GitHub Actions reusable workflows and how to fix it

A high-severity security finding was identified in `templates/claude-workflow/workflows/claude.yml` where `secrets: inherit` passed every repository secret to a reusable workflow, violating the principle of least privilege. The fix explicitly passes only `CLAUDE_CODE_OAUTH_TOKEN`—the single secret the called workflow actually needs—drastically reducing the blast radius if the reusable workflow is ever compromised.

high

How shell injection via `${{` variable interpolation happens in GitHub Actions and how to fix it

A high-severity shell injection vulnerability was discovered in `.github/commaSplitter/action.yaml` where unsanitized user input was directly interpolated into a bash `run:` step using `${{ inputs.input }}`. An attacker could craft a malicious input string to escape the shell command and execute arbitrary code on the GitHub Actions runner, potentially stealing secrets and source code. The fix introduces an intermediate environment variable to safely pass the input without shell interpretation.

high

How Missing Dependabot Cooldown happens in GitHub Actions and how to fix it

A high-severity supply chain vulnerability was discovered in a Dependabot configuration file that lacked cooldown periods for package updates. Without cooldown settings, Dependabot could propose updates to newly published—and potentially malicious—packages immediately after release. The fix adds a 7-day cooldown period to all three package ecosystems (npm, GitHub Actions, and Maven), giving the community time to identify compromised packages before they're automatically proposed.

critical

How Server-Side Request Forgery happens in Node.js CLI tools and how to fix it

A critical Server-Side Request Forgery (SSRF) vulnerability in the compass-guarded-transfer CLI tool allowed attackers to make HTTP requests to internal services and cloud metadata endpoints. The `normalizeInput` function in `run-transfer.mjs` validated that URLs started with "https://" but failed to prevent requests to private IP ranges like AWS metadata (169.254.169.254) or localhost, enabling potential credential theft and internal network reconnaissance.