In GitHub Actions, how do you pass a repository secret into a step safely?
answer
- it is a context, like github or env
- two consumption points, one expression syntax
- the shell should never re-parse the value
- the asterisks in the log are cosmetic
- fork pull requests get nothing
basics
~20 sReference it through the secrets context, as ${{ secrets.NAME }}, and map it into an env variable or an action input instead of splicing it into a shell command. GitHub masks the value in logs, but masking is redaction, not access control.
solid answer
~40 sSecrets reach a workflow through the `secrets` context. The two normal consumption points are an action input (`with: token: ${{ secrets.NPM_TOKEN }}`) and an environment variable (`env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }}`) that the script reads as `$NPM_TOKEN`. Prefer `env:` over interpolating the expression straight into a `run:` line: expression substitution happens before the shell parses the command, so a value containing a quote or a backtick can break or inject into that command. GitHub registers each secret value for log masking and prints `***` where it appears, but every step in the job can still read it, so masking guards against accidental printing, not against a malicious step. Secrets are also not supplied to runs triggered by a pull request from a fork, where they evaluate to an empty string.
code
yaml · 17 linesjobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# good: the value travels in the environment, the shell never re-parses it
- name: Deploy
run: ./scripts/deploy.sh
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
# also fine: the action declares the input
- uses: docker/login-action@v3
with:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}go deeper
Be ready to write the two lines from memory: the secrets context in an expression, bound through env: or with:. Know that the value is hidden in logs and that you never commit or echo it.
Explain why env: binding is safer than inline interpolation — expressions are substituted before the shell parses the command — and describe what log masking actually does to the output stream.
Demonstrate that a secret is visible to every step in the job, including third-party actions, and talk about scoping secrets to the narrowest job, pinning actions, and preferring short-lived credentials over stored ones.
Own the policy angle: which credentials are allowed to exist as stored secrets at all, how contributions from outside the trust boundary are handled, and what the blast radius is when any single workflow is compromised.
## What a GitHub Actions secret is A secret is an encrypted string stored by GitHub at repository, organization, or environment level. Once saved it is never displayed again in the UI or returned by the API. When a run starts, the secrets in scope for that run are decrypted and exposed to the job through the `secrets` context, read with expression syntax: `${{ secrets.MY_SECRET }}`. Secret names are case-insensitive, may contain only alphanumeric characters and underscores, may not begin with a number, and may not begin with `GITHUB_`, which is reserved. ## The two places you consume one **As an action input.** Most actions that need a credential take it as an input: - uses: docker/login-action@v3 with: username: ${{ secrets.REGISTRY_USER }} password: ${{ secrets.REGISTRY_PASSWORD }} **As an environment variable for a `run:` step.** Declare it under `env:` on the step (or the job) and read it from the shell: - run: ./deploy.sh env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} What you should avoid is putting the expression inside the command text itself, such as `run: ./deploy.sh --token=${{ secrets.DEPLOY_TOKEN }}`. Expressions are evaluated by the runner and the result is written into the script file **before** the shell runs it. A secret whose value contains a double quote, a backtick, a `$(` sequence, or a newline can therefore terminate the intended command and execute something else. Passing through `env:` avoids the problem completely, because the value travels in the process environment and is never re-parsed as source text. The same reasoning is why the same rule applies to untrusted expression values such as a pull-request title. ## Masking, and what it does not do When a secret is provided to a job, the runner registers its value with the log service. If that exact string later appears in step output, it is replaced with `***`. You can register additional values yourself with the `add-mask` workflow command: echo "::add-mask::$DERIVED_TOKEN" Masking is best-effort string replacement on the log stream. It does not survive transformations: if a step base64-encodes the secret, prints it one character per line, or writes it into an uploaded artifact, nothing is redacted. It also does not restrict access — every step in the job, including a third-party action you called, runs in the same environment and can read the same values. This is why third-party actions are pinned and reviewed, and why you scope secrets to the jobs that genuinely need them rather than declaring them in workflow-level `env`. Storing structured data such as a whole JSON blob in one secret is discouraged for the same reason: only the exact stored string is masked, so individual fields printed by a tool that parsed the JSON come out in clear text. ## Where secrets are not available Runs triggered by `pull_request` from a forked repository receive no secrets — the expressions evaluate to empty strings — and a read-only `GITHUB_TOKEN`. That is deliberate: the workflow file and the code it runs come from a contributor you have not vetted. A job that references an environment gets that environment's secrets only after the environment's protection rules pass. And a reusable workflow invoked with `workflow_call` receives no secrets unless the caller passes them explicitly or with `secrets: inherit`. ## Practical checklist - Read secrets through `secrets.NAME`; never commit one, and never echo one to debug. - Bind them with `env:` or `with:`, at the narrowest scope that works — step over job, job over workflow. - Treat masking as a typo net, not a control; assume any step in the job can exfiltrate what the job can see. - Expect empty values on fork pull requests, and design workflows that need credentials to run on a trusted trigger instead. - Prefer short-lived credentials (the job's `GITHUB_TOKEN`, or a cloud token obtained through OIDC) over a stored long-lived secret whenever the target supports it.
- Why prefer env: over interpolating ${{ secrets.X }} directly into a run: command?Expressions are substituted into the script before the shell parses it, so a value containing a quote, backtick, or `$(` can terminate the intended command and execute something else. Binding it under `env:` puts the value in the process environment, where the shell never re-parses it, and it also keeps the literal out of the generated script file.
- What does ${{ secrets.API_KEY }} evaluate to in a run triggered by a pull request from a fork?An empty string. GitHub withholds secrets from fork pull-request runs, and the `GITHUB_TOKEN` is read-only, because the code and workflow being run come from an untrusted contributor. Workflows that genuinely need credentials for fork contributions are usually split so the privileged half runs on a trusted trigger after review.
- A step prints a secret base64-encoded and it appears unmasked in the log. Why?Masking is literal string replacement on the log stream: only the exact registered value is redacted. Any transformation — base64, reversal, splitting across lines — produces a different string that the masker does not recognise. This is why masking is a protection against accidental printing, not a security boundary.
saying these in an interview costs you the question
- Says masked logs mean the secret cannot leak
- Interpolates secrets directly into run shell commands
- Thinks steps cannot read a secret they were not given
- Expects repository secrets to work on fork pull requests
- Stores a JSON blob and assumes every field is masked