This episode discusses managing sensitive data in GitHub Actions: how to store and use secrets, masking values in logs, preventing script injection, and applying the least privilege principle to GITHUB_TOKEN.

Since episode 6 we've been building increasingly complex pipelines: sequential jobs, matrix tests, artifacts, and caching. The more mature the pipeline, the more valuable things it accesses — and that's exactly where security comes in. Imagine a production API key leaking into a workflow's public log: within minutes your account could be taken over, and cloud bills would spike with no way to stop them.
The next episodes (9 and 10) are the security phase of this series. In episode 9 we discuss sensitive data management: storing secrets correctly, making sure their values never appear in logs, preventing script injection attacks from untrusted content, and trimming GITHUB_TOKEN access rights to the absolute minimum.
Secrets are encrypted values stored by GitHub and only readable by workflows via the secrets context. Once a secret is stored, you'll never be able to see its contents again — only overwrite it. This answers a key question: credentials never become part of the code, commits, or repository history.
There are three levels of secret scope:
| Scope | Applies to | Notes |
|---|---|---|
| Repository | One repository | Most common, easy to manage per-project |
| Environment | One environment (e.g., production) | Used together with protection rules |
| Organization | Selected repositories in an organization | For shared keys, e.g., a registry |
Environment secrets have special behavior: their values override repository secrets with the same name, and they're only available to jobs that declare that environment. This is how you separate staging tokens from production tokens in a single repository.
Access a secret via the secrets context, usually first moved into an env variable to be easy to use across several steps:
jobs:
deploy:
runs-on: ubuntu-latest
env:
API_TOKEN: ${{ secrets.API_TOKEN }}
steps:
- uses: actions/checkout@v4
- run: ./deploy.shGood news: GitHub automatically masks secret values that appear in logs — whether step output, errors, or tracebacks. What's written is only ***. However, automatic masking only applies to values stored as secrets. If a sensitive value is generated at runtime (for example a temporary token from an API response), you must mask it yourself with the ::add-mask:: command:
TOKEN_LAIN=$(curl -s https://api.example.com/token)
echo "::add-mask::$TOKEN_LAIN"After the second command, the value in TOKEN_LAIN is masked throughout that step — and in all subsequent outputs. This is the last line of defense if a secret value finds its way into logs.
This is one of the most serious vulnerabilities in GitHub Actions, and it's often overlooked. Look at this pattern:
- name: Cetak judul issue
run: echo "${{ github.event.issue.title }}"What's the problem? The value of github.event.issue.title is content controllable by others — anyone who creates an issue can write "; rm -rf /; # as the title. Because that value is interpolated directly into the shell script before execution, the attacker has injected their commands into your runner. This is called script injection.
The solution is simple and mandatory: never place untrusted context directly into run. Move it into an env variable first, because env assignment is not executed as shell:
- name: Cetak judul issue dengan aman
env:
JUDUL: ${{ github.event.issue.title }}
run: echo "$JUDUL"Now the title content is treated as data, not commands — characters like $, backticks, and semicolons won't be executed by the shell. The same principle applies to PR content, issue bodies, branch names, and comments: all of them are untrusted.
Warning
The golden rule: content from GitHub events is never trusted. Don't interpolate github.event, github.head_ref, or values parsed from user input directly into run without going through env. Also, commands that involve secrets should never write a secret value directly into run for "testing" — move it to env and read the variable instead. Furthermore, don't run self-hosted runners for public repositories without extra protection, because anyone can trigger a workflow on your machines.
Every workflow runs with a GITHUB_TOKEN — an automatic token that behaves like the github-actions account. If unrestricted, this token has fairly broad access to the repository. The least privilege principle demands: give only the permissions that job truly needs.
name: CI
on: [push]
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run lintThis workflow only needs to read code — so contents: read is enough. Workflows that push to GHCR need packages: write, workflows that create releases need contents: write. The best approach: start from permissions: {} (no permissions) then raise per job that truly needs it.
| Mistake | Symptom | Solution |
|---|---|---|
Writing a secret directly in run | Secret value can leak in logs | Move to env, read the variable |
| User context interpolated into shell | Runner can be executed by attacker | Always pass through env first |
| Token with full permissions in all jobs | Unnecessary exposure | Set permissions per workflow/job |
| Sensitive runtime value without mask | Leaks via step output | Add ::add-mask:: |
| Secret committed to the repository | Permanent leak in git history | Rotate the value, store as a secret |
Pipeline security is a responsibility that starts with small disciplines:
secrets context; choose repository, environment, or organization scope as needed.::add-mask:: is for runtime values.run — always pass through an env variable.GITHUB_TOKEN to the minimum via the permissions key.In the next episode 10, we raise it one level: Passwordless Cloud Authentication Using OIDC — removing long-term credentials from secrets entirely, and authenticating to AWS, GCP, and Azure with short-lived tokens. This is the biggest security leap you can make for your pipeline!