skip to content

Secrets & Environments

Getting credentials into a workflow safely: repo, org and environment secret scopes, the GITHUB_TOKEN permissions model, environment protection with reviewers and wait timers, and OIDC federation to a cloud role instead of stored keys. Heavily asked because OIDC plus least-privilege token permissions is the expected modern answer.

part ofGitHub Actionsoverview, primer and where to startread it →
on this pageshow

questions

6

In GitHub Actions, how do you pass a repository secret into a step safely?

level: juniorimportance: must knowfreq 78%

answer

  1. it is a context, like github or env
  2. two consumption points, one expression syntax
  3. the shell should never re-parse the value
  4. the asterisks in the log are cosmetic
  5. fork pull requests get nothing

basics

~20 s

Reference 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 s

Secrets 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 lines
yaml
jobs:
  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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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

context

open as a page

In GitHub Actions, what does the permissions key do to the GITHUB_TOKEN?

level: middleimportance: must knowfreq 70%

basics

~20 s

It sets the API scopes the job's GITHUB_TOKEN carries. Listing any scope drops every scope you did not list to none, so permissions: contents: read yields a token that can clone but cannot write issues, packages, or code.

open as a page

In GitHub Actions, which secret wins when org, repo, and environment names collide?

level: middleimportance: should knowfreq 62%

basics

~20 s

The most specific scope wins: an environment secret overrides a repository secret, which overrides an organization secret of the same name. The environment value only applies to jobs that declare that environment; other jobs still see the repository or organization value.

open as a page

How do you configure a GitHub Actions job to assume a cloud role through OIDC?

level: seniorimportance: should knowfreq 56%

basics

~20 s

Grant the job permissions: id-token: write, use the provider's login action to exchange the GitHub-issued token for temporary credentials, and on the cloud side trust GitHub's issuer while pinning the sub claim to the specific repository, branch, or environment.

open as a page

What do GitHub Actions environment protection rules gate, and how does the job behave?

level: seniorimportance: should knowfreq 58%

basics

~20 s

They gate the start of any job that declares that environment. Required reviewers, a wait timer, and deployment branch or tag policies must all pass before the job is dispatched, and only then are the environment's secrets issued to it.

open as a page

How would you keep production credentials unreachable from GitHub Actions runs on fork pull requests?

level: principalimportance: should knowfreq 40%

basics

~20 s

Rely on the default that fork pull requests get no secrets and a read-only token, keep privileged work out of pull-request-triggered workflows entirely, and put real credentials behind environments with reviewers so any privileged job must be approved before it starts.

open as a page