Secrets, Variables, and Configuration Management
Every deployment pipeline needs two categories of information beyond your source code: secrets that must never be exposed, such as credentials, tokens, and private keys, and variables that describe configuration but carry no risk if seen, such as regions, feature flags, and version numbers. GitHub Actions stores each differently and lets you scope both to a repository, an organization, or a specific environment such as staging or production. This lesson covers how secrets and variables are defined and referenced, how environment protection rules gate access to sensitive values, why workload identity federation reduces risk compared to long-lived keys, and the mistakes that most often leak credentials from a pipeline.
Overview: How Secrets and Variables Work
GitHub Actions secrets are encrypted at rest with a key unique to the repository. Once saved, a secret’s value cannot be viewed again through the UI or API, only overwritten. If a workflow step happens to print the exact stored value to the log, GitHub scans the output and replaces it with *** before the log is written to disk. Variables, referenced through the vars context, are stored as plain text instead. They exist for values that are safe to see in logs or diffs but still differ by environment: an API base URL, a runtime version, a list of enabled feature flags.
Both secrets and variables can be defined at three scopes. Repository scope makes a value available to every workflow in that repository. Organization scope shares a value across many repositories, optionally restricted to a chosen list. Environment scope makes a value available only to jobs that declare environment: <name>, and only after that environment’s protection rules pass. Environment scoping is what lets a DEPLOY_HOST secret point at a staging server for the staging environment and a different production server for the production environment, using the exact same workflow file. When names collide, the environment-scoped value wins over a repository or organization value with the same name.
The automatically generated GITHUB_TOKEN is a separate mechanism from secrets you create yourself. It is a short-lived token scoped to the current run. Its permissions default to a fairly broad read and write set unless the repository or organization owner has restricted the default, so every workflow should declare an explicit permissions: block requesting only what it needs. Most jobs need nothing more than contents: read; a job that authenticates to a cloud provider through OpenID Connect additionally needs id-token: write.
| Aspect | Secrets | Variables |
|---|---|---|
| Stored as | Encrypted, write-only after saving | Plain text, viewable in the UI |
| Context | secrets.NAME |
vars.NAME |
| Value in logs | Masked as *** when printed verbatim |
Shown as-is |
| Scopes | Repository, environment, organization | Repository, environment, organization |
| Typical use | Tokens, passwords, private keys, connection strings | Regions, flags, version numbers, non-sensitive URLs |
Syntax and Workflow Structure
Secrets and variables are read through expression contexts: ${{ secrets.NAME }} and ${{ vars.NAME }}. You can reference them directly inside a step’s with: block, but for anything passed to run:, route the value through an env: block first rather than interpolating it straight into the shell command string. The job-level environment: keyword is what activates environment-scoped secrets and variables, and it can be written as a simple string or as an object with a url field that shows a deployment link on the run summary.
permissions: can be set at the workflow level, where it becomes the default for every job, or at the job level, where it overrides the workflow default for that job only. A common pattern is a workflow-level default of contents: read with a single deployment job that adds id-token: write for OIDC, keeping every other job in the file at the minimum. Organization-level secrets and variables are consumed with the exact same syntax as repository-level ones; the only difference is where they were defined and which repositories the organization owner granted access to.
Examples
Example 1: A repository secret used for deployment
name: Deploy to Staging
on:
push:
branches: [main]
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: staging
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy via SSH
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
curl -fsSL \"https://$DEPLOY_HOST/api/deploy\" \\
-H \"Authorization: Bearer $DEPLOY_TOKEN\" \\
-d \"ref=$GITHUB_SHA\"
The job runs against the staging environment, so DEPLOY_HOST and DEPLOY_TOKEN resolve from secrets attached to that environment. Because the values are passed through env: rather than written inline in the command, they never appear as literal text in the workflow file’s expanded command line, and any log line that happens to include the exact token text is replaced with asterisks.
Example 2: Environment protection, variables, and OIDC
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: read
id-token: write
jobs:
build:
runs-on: ubuntu-latest
outputs:
image_digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- name: Build image
id: build
run: |
docker build -t app:${{ github.sha }} .
echo \"digest=$(docker inspect --format='{{.Id}}' app:${{ github.sha }})\" >> \"$GITHUB_OUTPUT\"
deploy-production:
needs: build
runs-on: ubuntu-latest
environment: production
env:
DEPLOY_REGION: ${{ vars.DEPLOY_REGION }}
MAX_REPLICAS: ${{ vars.MAX_REPLICAS }}
steps:
- name: Configure cloud credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
aws-region: ${{ vars.DEPLOY_REGION }}
- name: Deploy
run: |
echo \"Deploying digest ${{ needs.build.outputs.image_digest }} to $DEPLOY_REGION with $MAX_REPLICAS replicas\"
./deploy.sh --region \"$DEPLOY_REGION\" --replicas \"$MAX_REPLICAS\"
The build job produces an image digest as an output. The deploy-production job declares environment: production, so if that environment has required reviewers configured, the job pauses until someone approves it. Once approved, it assumes an AWS role through OpenID Connect instead of reading a stored access key, and reads its region and replica count from non-sensitive variables that can differ between the staging and production environments without touching the workflow file.
Example 3: Rendering a config file from mixed variables and secrets
name: Configure and Deploy
on:
workflow_dispatch:
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Render environment config
env:
API_BASE_URL: ${{ vars.API_BASE_URL }}
FEATURE_FLAGS: ${{ vars.FEATURE_FLAGS }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
run: envsubst < config.template.json > config.production.json
- name: Deploy using rendered config
run: ./deploy.sh --config config.production.json
- name: Remove rendered config
if: always()
run: rm -f config.production.json
envsubst substitutes the placeholders in config.template.json with the environment variables, producing a file that contains the database password in plaintext on the runner’s disk. The deploy script reads that file directly rather than the file being uploaded anywhere. The final step, guarded by if: always(), deletes the rendered file at the end of the job whether the deploy succeeded or failed, so a file containing a secret never lingers or gets picked up by an unrelated later step such as a cache upload.
Step by Step
- Open the repository’s Settings, then Secrets and variables, then Actions, and add a repository secret or repository variable.
- Under Settings, then Environments, create an environment such as
stagingorproduction. - On that environment, optionally add required reviewers, a wait timer, or a restriction to specific branches or tags.
- Add secrets and variables scoped to that specific environment; they only resolve for jobs that declare
environment: <name>. - In the workflow file, set
environment: production(or the matching name) on the job that needs those values. - For cloud deployments, register a trust relationship in the cloud provider that trusts GitHub’s OIDC issuer, scoped to a specific repository and branch or tag pattern, instead of generating a long-lived access key to store as a secret.
- Validate the setup against a low-stakes environment without required reviewers before wiring the same pattern into production.
Common Mistakes
Mistake 1: Interpolating a secret directly into a shell string
- name: Bad - interpolates the secret directly into the shell string
run: echo \"Deploying with token ${{ secrets.DEPLOY_TOKEN }}\"
GitHub masks the exact secret text if it appears verbatim in the log, but the raw value is still substituted into the command before the shell ever runs it, so it is exposed to anything that can read process arguments, and any transformation of the value, such as encoding or splitting it, is no longer recognized and will not be masked at all. Pass secrets through an environment variable instead:
- name: Good - pass the secret through an environment variable
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: ./deploy.sh
deploy.sh receives DEPLOY_TOKEN as an environment variable, and nothing about the secret ever appears as literal text in the expanded run: command.
Mistake 2: Exposing secrets to untrusted fork code with pull_request_target
on:
pull_request_target:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm ci && npm test
pull_request_target runs with access to repository secrets and a token that can write to the base repository, unlike pull_request. Checking out the fork’s own head commit and then running npm ci, which executes install scripts, means arbitrary code from an untrusted contributor runs with NPM_TOKEN available to it. Build and test untrusted contributions on pull_request instead, which carries no repository secrets and a read-only default token:
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
If a privileged step genuinely needs to run after a fork PR, such as posting a comment or triggering a deploy preview, keep it in a separate workflow that never checks out or executes the fork’s code, or gate it behind an environment that requires a maintainer’s approval before it runs.
Best Practices
- Set an explicit, minimal
permissions:block on every workflow; addid-token: writeonly on the job that performs OIDC authentication. - Prefer OpenID Connect federation with your cloud provider over storing long-lived access keys as secrets, since a federated credential is issued per run and expires automatically.
- Scope secrets to the environment that uses them rather than storing one shared set at the repository level, so a compromised staging credential cannot reach production.
- Require reviewers on protected environments such as production, and restrict which branches or tags are allowed to deploy to them.
- Treat every value that comes from a transformation of a secret as still sensitive. Mask derived values explicitly with the
add-maskworkflow command:
RAW_SECRET=\"${DB_PASSWORD}\"
DERIVED_TOKEN=$(echo -n \"$RAW_SECRET\" | base64)
echo \"::add-mask::$DERIVED_TOKEN\"
echo \"token=$DERIVED_TOKEN\" >> \"$GITHUB_OUTPUT\"
- Never store anything sensitive in
vars; it exists specifically because its contents are readable in the UI and in logs. - Pin third-party actions to a full commit SHA rather than a mutable tag when the action has broad permissions or handles secrets, and update the pin deliberately; a tag is convenient to read but can be moved by its maintainer, while a SHA is immutable at the cost of a less readable diff.
- Rotate secrets on a schedule and immediately after any suspected exposure, and check the repository’s audit log periodically for unexpected secret reads or environment changes.
- Prefer an image digest over a mutable tag when pulling a container for deployment, since a digest always resolves to the exact bytes that were built and scanned, while a tag can be overwritten later.
Practice Exercises
- Create a
stagingand aproductionenvironment in a test repository, add a differently valued placeholder variable to each, and write a workflow that prints which one resolved for a given job. - Add a required reviewer to the production environment and observe how a workflow run pauses until it is approved.
- Take a workflow step that interpolates a secret directly into a
run:line and rewrite it to pass the value throughenv:instead. - Review a workflow that uses
pull_request_targetand decide whether it checks out or executes any code from the pull request; if it does, redesign it to usepull_requestor to remove the checkout of untrusted code. - Write a workflow that renders a configuration file from a mix of
varsandsecrets, then deletes that file withif: always()once it is no longer needed.
Summary
Secrets and variables solve two different problems: secrets protect values that must never be seen, while variables carry configuration that is safe to read but still needs to differ by environment. Scoping both to environments, combined with required reviewers on production, lets one workflow file safely serve multiple deployment targets. Favor short-lived OIDC credentials over stored keys, keep permissions minimal and explicit, route every secret through an environment variable rather than a shell string, and treat pull requests from forks as untrusted by default. These habits are what separate a pipeline that merely works from one that is safe to run against production.
