Secrets in GitHub Actions logs are more common than most teams would like. Nobody deliberately writes echo $PASSWORD. What actually happens is that a Base64-encoded header, the JSON output of aws sts assume-role or a chatty debug log slips past automatic masking. Since November 9, 2026, GitHub scans these logs for secrets itself, but for now only for enterprise customers with Advanced Security.
This topic matters to me because workflow logs feel like they belong to nobody. They are created as a by-product, sit around for weeks, and anyone with read access to the repository can open them. In this article we look at what the new GitHub feature can and cannot do, how secrets end up in logs in the first place, and how to harden your workflows step by step. For everyone without an enterprise license, we also build a small log scanner in TypeScript that checks your recent workflow runs.
In short
GitHub only masks values in workflow logs that the runner knows to be secrets. Derived values such as Base64 strings, JWTs or JSON output with credentials slip through and stay readable for 90 days by default. The most important protection is to register every newly created sensitive value with ::add-mask:: right away, and when you find a leak, to rotate first and delete the log afterwards.
What you will learn in this article
- What the new secret scanning for workflow logs covers and what it misses
- The four ways secrets slip past masking
- How to harden workflows step by step with OIDC and ::add-mask::
- How to check your own workflow logs with a TypeScript script
- In which order to react when you find a leak
What GitHub announced on November 9
GitHub has extended secret scanning to GitHub Actions logs (changelog from November 9, 2026). Until now, secret scanning mainly covered code, issues, pull requests, wikis and discussions. Now GitHub also checks the log of every completed workflow run for known provider patterns, for example AWS keys, GitHub tokens or keys for other cloud services. Where GitHub supports it, it also checks whether a detected secret is still valid.
Findings show up in the repository’s regular list of secret scanning alerts, with a link to the affected log. A nice detail: the alert remains even if the log is deleted later. So the trail is not lost when someone cleans up in a hurry.
The limits of the preview
Before you lean back, read the fine print. The feature is a public preview, and only enterprises that use GitHub Enterprise Cloud and have licensed GitHub Secret Protection or GitHub Advanced Security can use it. It is also turned off by default: enterprise owners have to enable the “Secret scanning for Actions workflow logs” policy in the enterprise settings under “Policies” and “Advanced Security”.
| Question | Answer according to the changelog |
|---|---|
| Who can use it? | GitHub Enterprise Cloud with Secret Protection or Advanced Security |
| Are old logs checked? | No, only runs that complete after the feature is enabled |
| Which patterns? | Provider patterns; no generic, no custom and no AI-detected secrets |
| Notifications? | None during the preview, you have to check the alert list yourself |
| Values already masked? | Not reported, since they are not in the log in plain text |
For many teams this means: the new feature helps once it is enabled in the enterprise. For personal accounts, organizations without Advanced Security and all existing logs, nothing changes for now. That is why it pays to understand the causes.
How secrets end up in GitHub Actions logs
The runner, the machine that executes your job, replaces every value it knows as a secret with *** in the output. These are the secrets you use in the workflow via ${{ secrets.NAME }}, plus all values you register yourself with ::add-mask::. Everything else is written to the log as is. GitHub’s own security guide stresses that automatic redaction is not guaranteed. In practice, there are four main ways secrets show up in GitHub Actions logs.
The four most common ways secrets slip past masking into the workflow log
Derived values
You store a password as a secret, but in the workflow you build a basic auth header from it. The runner knows the password, but not the Base64 string derived from it. The same applies to a JWT you sign with a private key, or to a URL-encoded value. GitHub explicitly recommends registering such derived values as secrets too.
Structured output
A classic in AWS setups: a step calls aws sts assume-role and prints the result. The response is a JSON document containing AccessKeyId, SecretAccessKey and SessionToken. None of these values is registered with the runner, so all of it lands in the log in plain text. The keys are only valid temporarily, but an hour is plenty of time for mischief. In addition, GitHub advises against storing secrets as JSON, XML or YAML, because redaction can fail with structured data. Create a separate secret for each sensitive value instead.
Debug output and set -x
With set -x, the shell prints every command after variable expansion. If you fetch a value at runtime, for example from Parameter Store, it appears in the log in the line + key=… before you had a chance to mask it. Debug logging is similarly risky: with the secret or variable ACTIONS_STEP_DEBUG set to true, additional debug messages appear, and many tools become very talkative in debug mode.
Error messages from tools
The fourth path is the sneakiest, because it only happens when something fails. An HTTP client throws an exception and prints the complete request including headers. A deployment tool shows the resolved configuration on a validation error. That is why GitHub recommends testing workflows with invalid inputs as well and reviewing the logs afterwards. It sounds tedious, but it finds exactly the cases that never stand out in normal green runs.
🎬 How a secret ends up in a workflow log and gets found – this animated diagram only runs on my site: watch it on sascha-ms.de
Step by step: hardening your workflow
Let’s get practical. Our example is a fictional bike rental shop that deploys its Serverless application to AWS with GitHub Actions and needs an API key from a partner.
Prerequisites
- A GitHub repository with at least one workflow
- For the AWS example: an IAM role that trusts GitHub via OIDC
- Node.js 22 or newer for the log scanner further down
- A fine-grained token with read access to “Actions” (for the scanner)
Step 1: OIDC instead of stored access keys
The best secret is one that does not exist. With OpenID Connect (OIDC), the workflow exchanges a short-lived token from GitHub for temporary AWS credentials. There is no long-lived access key left that could end up in a log. I described in detail how to set up the trust relationship in AWS in GitHub Actions OIDC: AWS deployments without access keys. The configure-aws-credentials action handles the sign-in, and you never have to print the credentials yourself.
Step 2: Fetch values at runtime and mask them immediately
If you need a value that is not stored as a GitHub secret, fetch it at runtime and register it with ::add-mask:: right away, before it shows up anywhere else. According to the documentation on workflow commands, masking applies to the whole job, but has to be set again in every job. Here is the complete workflow:
# examples/deploy-safe.yml name: deploy-bike-rental on: push: branches: [main] permissions: id-token: write # OIDC instead of stored access keys contents: read jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam::123456789012:role/bike-rental-deploy aws-region: eu-central-1 - name: Load partner API key and mask it run: | key=$(aws ssm get-parameter --name /bike-rental/partner-api-key \ --with-decryption --query Parameter.Value --output text) echo "::add-mask::$key" echo "PARTNER_API_KEY=$key" >> "$GITHUB_ENV" - name: Build a derived value and mask it as well env: PARTNER_PASSWORD: ${{ secrets.PARTNER_PASSWORD }} run: | basic=$(printf 'rental:%s' "$PARTNER_PASSWORD" | base64 -w0) echo "::add-mask::$basic" echo "PARTNER_BASIC_AUTH=$basic" >> "$GITHUB_ENV" - name: Deploy run: npx serverless deploy --stage prod The order in the “Load partner API key and mask it” step is what matters: command substitution stores the value in a variable without printing it. Only then does ::add-mask:: follow. From that moment on, the runner replaces every occurrence with ***, including in later steps of the same job.
Step 3: Register derived values as well
The second step shows the pattern for derived values. The password itself is known as a secret, but the Base64 string built from it is not. That is why it gets its own ::add-mask::. A good rule of thumb: every line that computes something new from a secret needs a masking line directly after it.
Step 4: No set -x, and be careful with debug logs
Avoid set -x in steps that handle sensitive values. If you still want tracing, turn it off with set +x before loading the secret and switch it back on afterwards. Enable debug logging only for a single re-run when you need it, not permanently via a variable.
Step 5: Shorten the retention period
By default, GitHub keeps logs and artifacts for 90 days. In the repository or organization settings under “Actions” and “General”, you can shorten this period. For most deployment workflows, 14 to 30 days are plenty. A leak that disappears after two weeks is still a leak, but the window becomes much smaller.
Your own scanner for GitHub Actions logs in TypeScript
If you do not have an Advanced Security license or want to check existing logs, a small script helps. It uses the REST API for workflow runs to fetch the completed runs of the last few days, downloads their logs as a zip archive and searches every line for typical secret patterns. For unzipping we use the small library fflate; apart from that, built-in Node.js features are enough.
Create the project
mkdir actions-log-secret-scan && cd actions-log-secret-scan npm init -y npm pkg set type=module npm install fflate npm install -D typescript tsx vitest @types/node { "compilerOptions": { "target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "noEmit": true, "skipLibCheck": true, "types": ["node"] }, "include": ["src", "test"] } The detection rules
The rules are deliberately narrow. A generic pattern such as “long string with high entropy” would flag every commit hash, and after the third false alarm nobody looks anymore. In addition, the scanner never prints the whole secret, only the first four characters and the length. Otherwise you would simply copy the leak into your terminal or the next CI output.
// src/detectors.ts export interface Rule { id: string; description: string; pattern: RegExp; } export interface Finding { rule: string; file: string; line: number; preview: string; } // Deliberately specific patterns: few false positives instead of endless noise. export const rules: Rule[] = [ { id: "aws-access-key-id", description: "AWS access key ID", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g }, { id: "aws-secret-access-key", description: "AWS secret access key next to its name", pattern: /(?:aws_secret_access_key|SecretAccessKey)["']?\s*[:=]\s*["']?[A-Za-z0-9/+=]{40}/gi, }, { id: "aws-session-token", description: "AWS session token next to its name", pattern: /(?:aws_session_token|SessionToken)["']?\s*[:=]\s*["']?[A-Za-z0-9/+=]{100,}/gi }, { id: "github-token", description: "GitHub token", pattern: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9_]{82}\b/g }, { id: "npm-token", description: "npm access token", pattern: /\bnpm_[A-Za-z0-9]{36}\b/g }, { id: "private-key", description: "Private key block", pattern: /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----/g }, { id: "jwt", description: "JSON Web Token", pattern: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g }, { id: "url-credentials", description: "Password inside a URL", pattern: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s:/@*]{3,}@/gi }, { id: "slack-webhook", description: "Slack webhook URL", pattern: /https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/]{20,}/g }, ]; // Never print the secret itself: only the first characters and its length. export function redact(value: string): string { return `${value.slice(0, 4)}…(${value.length} chars)`; } export function scanText(text: string, file: string): Finding[] { const findings: Finding[] = []; const lines = text.split(/\r?\n/); lines.forEach((content, index) => { for (const rule of rules) { for (const match of content.matchAll(rule.pattern)) { findings.push({ rule: rule.id, file, line: index + 1, preview: redact(match[0]) }); } } }); return findings; } Unzip and search the log archive
The log of a run comes as a zip file with one text file per job and step. The function unpacks the archive in memory and runs every file through the rules:
// src/scan-zip.ts import { unzipSync, strFromU8 } from "fflate"; import { scanText, type Finding } from "./detectors.js"; // A workflow log archive contains one text file per job and step. export function scanLogArchive(zip: Uint8Array): Finding[] { const files = unzipSync(zip); const findings: Finding[] = []; for (const [name, data] of Object.entries(files)) { if (!name.endsWith(".txt")) continue; findings.push(...scanText(strFromU8(data), name)); } return findings; } Fetch runs and download logs
The command line script queries the completed runs, downloads the logs for each run and prints findings grouped by run. The log endpoint responds with a redirect to a download link that is only valid for one minute. fetch follows the redirect automatically, which is why the script downloads each log right before scanning it.
// src/scan-runs.ts import { parseArgs } from "node:util"; import { scanLogArchive } from "./scan-zip.js"; const { values } = parseArgs({ options: { repo: { type: "string" }, days: { type: "string", default: "14" }, max: { type: "string", default: "50" }, }, }); const token = process.env.GITHUB_TOKEN; if (!values.repo || !token) { console.error("Usage: GITHUB_TOKEN=... npm run scan -- --repo owner/name [--days 14] [--max 50]"); process.exit(2); } const api = "https://api.github.com"; const headers = { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", }; interface WorkflowRun { id: number; name: string; html_url: string; created_at: string; } async function listRuns(repo: string, days: number, max: number): Promise<WorkflowRun[]> { const since = new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10); const query = new URLSearchParams({ status: "completed", per_page: String(Math.min(max, 100)), created: `>=${since}` }); const res = await fetch(`${api}/repos/${repo}/actions/runs?${query}`, { headers }); if (!res.ok) throw new Error(`Listing runs failed: HTTP ${res.status}`); const body = (await res.json()) as { workflow_runs: WorkflowRun[] }; return body.workflow_runs.slice(0, max); } async function downloadLogs(repo: string, runId: number): Promise<Uint8Array | undefined> { // GitHub answers with 302 and a download link that is valid for one minute; fetch follows it. const res = await fetch(`${api}/repos/${repo}/actions/runs/${runId}/logs`, { headers }); if (res.status === 404 || res.status === 410) return undefined; // logs expired or deleted if (!res.ok) throw new Error(`Log download for run ${runId} failed: HTTP ${res.status}`); return new Uint8Array(await res.arrayBuffer()); } const runs = await listRuns(values.repo, Number(values.days), Number(values.max)); console.log(`Checking ${runs.length} workflow runs in ${values.repo} …`); let total = 0; for (const run of runs) { const zip = await downloadLogs(values.repo, run.id); if (!zip) continue; const findings = scanLogArchive(zip); if (findings.length === 0) continue; total += findings.length; console.log(`\n${run.name} (#${run.id}, ${run.created_at})\n${run.html_url}`); for (const f of findings) { console.log(` [${f.rule}] ${f.file}:${f.line} ${f.preview}`); } } if (total > 0) { console.log(`\n${total} possible secrets found. Rotate first, then delete the logs.`); process.exit(1); } console.log("No suspicious values found."); Write tests
A few Vitest tests make sure the rules do not break silently. One detail matters to me here: the test data is assembled at runtime. That way no secret scanner reports your test file as a leak, not even GitHub’s.
// test/detectors.test.ts import { describe, expect, it } from "vitest"; import { zipSync, strToU8 } from "fflate"; import { scanText } from "../src/detectors.js"; import { scanLogArchive } from "../src/scan-zip.js"; // Test data is assembled at runtime so that no scanner flags this file. const fakeKeyId = "AKIA" + "EXAMPLE7EXAMPLE7"; const fakeSecret = "wJalrXUtnFEMI/K7MDENG/" + "bPxRfiCYEXAMPLEKEY01"; const fakeGitHub = "ghp_" + "a".repeat(36); describe("scanText", () => { it("finds an AWS key ID in a log line", () => { const log = `2026-11-10T09:00:00.0000000Z Using key ${fakeKeyId}`; const findings = scanText(log, "deploy/3_Configure.txt"); expect(findings).toHaveLength(1); expect(findings[0]).toMatchObject({ rule: "aws-access-key-id", line: 1 }); }); it("finds credentials from assume-role JSON output", () => { const log = `{\n "AccessKeyId": "${"ASIA" + "EXAMPLE7EXAMPLE7"}",\n "SecretAccessKey": "${fakeSecret}"\n}`; const rules = scanText(log, "x.txt").map((f) => f.rule); expect(rules).toEqual(["aws-access-key-id", "aws-secret-access-key"]); }); it("never prints the full secret", () => { const [finding] = scanText(`token=${fakeGitHub}`, "x.txt"); expect(finding.preview).toBe("ghp_…(40 chars)"); expect(finding.preview).not.toContain(fakeGitHub); }); it("ignores values already masked by Actions", () => { expect(scanText("AWS_SECRET_ACCESS_KEY: ***", "x.txt")).toEqual([]); expect(scanText("https://deploy:***@git.example.com/repo.git", "x.txt")).toEqual([]); }); it("finds a password inside a URL", () => { const [finding] = scanText("cloning https://deploy:s3cr3tpass@git.example.com/repo.git", "x.txt"); expect(finding.rule).toBe("url-credentials"); }); }); describe("scanLogArchive", () => { it("scans every text file in the zip", (...


