Lesson 18 of 30
Every `uses:` line in a workflow runs someone else's code with access to your secrets. Treat that seriously and pin, scope, and never echo — or don't, and find out the hard way.
16 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
A CI pipeline isn't just your code — it's your code plus every third-party GitHub Action you reference, all running on a machine that also has your repository's secrets injected into it. That's a real attack surface, not a theoretical one: in March 2025, a widely-used action called tj-actions/changed-files was compromised — its tag was repointed to malicious code that dumped CI runner memory into workflow logs, exposing secrets in any repo that referenced it by a moving version tag. Nobody who used that action changed a single line of their own workflow file. The action itself changed underneath them.
Three concrete defenses map directly onto that story. Pin third-party actions to a full commit SHA, not a tag or branch — a SHA is immutable, so @a1b2c3d... always resolves to the exact code you reviewed, while @v4 can be silently repointed by anyone who controls that repo. Scope the GITHUB_TOKEN to the minimum permissions a job actually needs — the token every workflow gets automatically can, depending on repo and org defaults, carry broad write access; declaring an explicit permissions: block caps what a compromised or malicious step could do with it even if something in the pipeline does go wrong. Never let a secret reach a log line, in any form — GitHub's automatic masking only catches the literal string it was told about, not anything derived from it.
name: Build and deploy
on:
push:
branches: [main]
permissions: {} # deny everything by default; grant per-job below
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read # checkout needs this
deployments: write # this job creates a deployment status, nothing else
steps:
# pinned by SHA, tag kept only as a human-readable comment — this exact
# commit is what runs, no matter what the "v4" tag points to tomorrow
- uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v4.1.1
- name: Deploy
run: ./scripts/deploy.sh
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} # passed as env, never as a CLI arg
Two things worth noticing. First, permissions: {} at the workflow level denies everything by default, and the job re-grants only contents: read and deployments: write — nothing this job does needs, say, issues: write or packages: write, so it doesn't get them. Second, DEPLOY_TOKEN is passed through the environment, not interpolated into the run: command string — a secret embedded directly in a shell command can end up visible in that runner's process list, which an env var injected at execution time avoids.
Compare that against what not to do:
- name: Deploy (don't do this)
run: ./scripts/deploy.sh --token ${{ secrets.DEPLOY_TOKEN }}
Two problems at once: the token sits in the process's argument list, and if deploy.sh itself logs its own invocation for debugging, the token prints in plain text — GitHub's masking only redacts a value if it appears in output exactly as the secret was defined, so a script that logs Deploying with token: dep_a1b2... printed via string concatenation, base64 encoding, or truncation can slip straight past it.
GITHUB_TOKEN permissions aren't something to assume. Whether the implicit default is broad or read-only depends on the repo and org's own settings, not on some universal GitHub default you can rely on — declare the permissions: block explicitly instead of trusting whatever the current default happens to be.Getting secrets into a pipeline securely — where they live, how they're injected, keeping platform vs. user-provided credentials separate — is its own earlier lesson. This one assumes that part is already done right and asks the next question: given that a pipeline runs third-party code with access to those secrets anyway, what limits the damage when one of those third parties turns out to be compromised.