Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 17 min read

Secrets in GitHub Actions logs: find and prevent leaks

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 cha

Secrets in GitHub Actions logs: find and prevent leaks

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.

Diagram: four ways secrets end up in GitHub Actions logs – derived values, JSON output, debug output, error messages

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:[email protected]/repo.git", "x.txt");
    expect(finding.rule).toBe("url-credentials");
  });
});

describe("scanLogArchive", () => {
  it("scans every text file in the zip", () => {
    const zip = zipSync({
      "build/1_Set up job.txt": strToU8("nothing to see"),
      "build/4_Debug.txt": strToU8(`line one\nexport NPM_TOKEN=${"npm_" + "b".repeat(36)}`),
    });
    const findings = scanLogArchive(zip);
    expect(findings).toEqual([
      { rule: "npm-token", file: "build/4_Debug.txt", line: 2, preview: "npm_…(40 chars)" },
    ]);
  });
});

Run it and expected result

npx vitest run
export GITHUB_TOKEN=github_pat_YOUR_TOKEN
npx tsx src/scan-runs.ts --repo bike-rental/booking-api --days 30

The tests should finish with β€œ6 passed”. For each affected run, the scan prints the name, the link and every finding, for example [aws-secret-access-key] deploy/4_Assume role.txt:12 Secr…(59 chars). If it finds nothing, it exits with code 0, and with 1 if it finds something. So you can also run it as a scheduled workflow that turns red when it finds a leak.

Common errors

  • HTTP 401 or 403: the token lacks read access to β€œActions”, or it is not granted for this repository.
  • Empty results despite many runs: older logs are gone once the retention period has expired; the script skips them silently.
  • Rate limits in large repositories: start with --max 20 and increase step by step.
  • Findings in test data: check whether the value really is a secret. Example keys from documentation are harmless, real tokens are not.

One more note on cost and security: the GitHub API is free for this purpose, but the script needs a token with read access to Actions. Do not give it any other permissions, and let it expire after the scan.

What to do when a secret shows up in a log

The order is crucial. The reflex is to delete the log immediately. That feels good, but it does not protect you, because someone may have copied the secret already. GitHub recommends both: delete the log and rotate the secret. This is how I would do it:

  1. Rotate the secret: create a new key, store it in your secret store and revoke the old one. For temporary AWS credentials from a role, you can invalidate active sessions in the IAM console.
  2. Check usage: look in CloudTrail or the provider’s logs to see whether the old key has been used since the run.
  3. Delete the log: in the UI or via the API, see below.
  4. Fix the cause: add ::add-mask:: to the step or remove the output entirely, otherwise the new secret will be in the log again on the next run.

Deleting the logs of a run takes a single call with the GitHub CLI. On success, the API responds with status 204:

gh api -X DELETE repos/bike-rental/booking-api/actions/runs/1234567890/logs

My take

I like the new feature, because it closes a gap that many teams do not even have on their radar. Logs are considered a by-product, and that is exactly why nobody looks at them. Keeping alerts after the logs are deleted is a smart decision.

Still, I would not rely on it. The feature only detects provider patterns, sends no notifications during the preview, and is not available at all to most developers without an enterprise license. The more effective lever is the workflow itself: OIDC instead of long-lived keys, consistent masking and short retention. A scanner, whether from GitHub or built yourself, is the safety net underneath, not a replacement for clean workflows. If you set up your deployment as described in Serverless Framework with GitHub Actions, you are already halfway there.

FAQ

Does GitHub Actions mask secrets in logs automatically?

Yes, but only values the runner knows: secrets from ${{ secrets.* }} and values you register with ::add-mask::. It does not reliably recognize derived or structured values.

How long are GitHub Actions logs kept?

90 days by default. You can shorten the retention period in the Actions settings of the repository or organization.

Can I delete the logs of a single workflow run?

Yes, in the UI via the run’s menu, or via the API with DELETE /repos/{owner}/{repo}/actions/runs/{run_id}/logs. You need write access to the repository for this.

Do I need a license for secret scanning in workflow logs?

Yes. The preview is limited to GitHub Enterprise Cloud with Secret Protection or Advanced Security, and enterprise owners have to enable it.

Your next step

Open the log of your latest deployment workflow today and search for the words β€œsecret”, β€œtoken” and β€œauthorization”. If that is too tedious, run the scanner from this article over the last 30 days. And then I would like to know: which step in your pipelines has ever written something to a log that did not belong there?

All code from this article, ready to run: smirowstanitzok/sascha-ms-examples

Sources

This post was created with AI assistance.

πŸ‘‰ Next step: Not sure which AWS service fits your use case? My AWS service finder asks a few questions and points you to the right one.

πŸ“¬ Stay updated: New articles on serverless, AI and leading tech teams via RSS, browser push or the weekly newsletter (in German).

Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.