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

  1. Open the repository’s Settings, then Secrets and variables, then Actions, and add a repository secret or repository variable.
  2. Under Settings, then Environments, create an environment such as staging or production.
  3. On that environment, optionally add required reviewers, a wait timer, or a restriction to specific branches or tags.
  4. Add secrets and variables scoped to that specific environment; they only resolve for jobs that declare environment: <name>.
  5. In the workflow file, set environment: production (or the matching name) on the job that needs those values.
  6. 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.
  7. 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; add id-token: write only 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-mask workflow 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

  1. Create a staging and a production environment 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.
  2. Add a required reviewer to the production environment and observe how a workflow run pauses until it is approved.
  3. Take a workflow step that interpolates a secret directly into a run: line and rewrite it to pass the value through env: instead.
  4. Review a workflow that uses pull_request_target and decide whether it checks out or executes any code from the pull request; if it does, redesign it to use pull_request or to remove the checkout of untrusted code.
  5. Write a workflow that renders a configuration file from a mix of vars and secrets, then deletes that file with if: 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.