env.dev

GitHub Actions: Secrets vs Environment Variables

When to use repository secrets, environment secrets, and configuration variables in GitHub Actions. Includes workflow examples for Node.js, Python, and Docker.

By env.dev Updated

GitHub Actions provides three distinct mechanisms for passing configuration into workflows: repository secrets, environment secrets, and configuration variables. Secrets are encrypted at rest, masked in logs, and never exposed to forks. Configuration variables are plaintext and suited for non-sensitive values like region names or feature flags. Choosing the wrong type leaks credentials or creates unnecessary friction. This guide covers when to use each, how to reference them in YAML, and the mistakes that trip up even experienced developers.

How do cache permissions change a release workflow?

GitHub added cache-mode on September 10, 2026. Set read for restore-only jobs, write for restore/save, write-only for save-only, or none to disable cache access. Job settings override workflow settings; reusable workflows cannot exceed the caller’s permission. Granting writes to low-trust events can enable cache poisoning. GitHub’s release note documents the rollout.

yaml
name: Verify pull request
on: pull_request
permissions:
  contents: read
cache-mode: read
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: '24'
          cache: npm
      - run: npm ci
      - run: npm test

This example needs a committed package-lock.json and an npm test script. Expected result: dependencies restore when a matching cache exists, tests run, and no new cache is saved. For release jobs use cache-mode: none and disable setup-node’s automatic cache. Cache permissions do not make untrusted code safe to run with credentials. Do not check out untrusted PR code in privileged pull_request_target workflows. See the workflow syntax reference.

How do I publish to npm without a long-lived token?

Configure an npm trusted publisher for the exact repository, workflow filename and deployment environment. OIDC requires id-token: write, supported hosted runners, Node 22.14+ and npm 11.5.1+ for direct publishing. The workflow below uses Node 24 and requires an existing publishable package with a lockfile; verify npm --version before running. No npm token is supplied. npm’s trusted publishing documentation covers setup and restrictions.

yaml
name: Publish approved release
on: workflow_dispatch
permissions:
  contents: read
  id-token: write
cache-mode: none
jobs:
  publish:
    runs-on: ubuntu-latest
    environment: npm-release
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: '24'
          registry-url: 'https://registry.npmjs.org'
          package-manager-cache: false
      - run: npm --version
      - run: npm ci
      - run: npm test
      - run: npm run build --if-present
      - run: npm publish --access public

Configure required reviewers on npm-release before use. This direct-publish example also requires direct publishing permission on the matching npm configuration. Expected result after approval: the package version becomes available in the registry; the workflow performs a real publish, not a dry run. Pin action revisions to reviewed commits for your own release policy.

Since September 3, 2026, a package can have multiple additive trusted-publisher configurations. A match against any one authorizes access; one narrow entry cannot restrict a broader entry. New configurations permit staging by default. For a separate registry approval boundary, use npm stage publish with a CLI that supports staging and leave direct publish disabled. Approval waits for malware scanning; staging is not a public release. Audit every configuration when separating stable and prerelease workflows. GitHub’s npm update explains these boundaries.

Why is npm publishing blocked after account recovery?

A successful recovery-code sign-in now starts a 72-hour security hold for every npm account, announced September 9, 2026. Publishing and sensitive writes such as token creation pause; sign-in, browsing and installs still work. Check whether recovery-code use explains the timing before rotating CI credentials. The hold expires automatically. If recovery was unexpected, contact npm Support. This diagnosis does not explain every authentication failure; also check workflow identity and publisher permissions. Source: npm recovery-code hold announcement.

What are the three types of workflow configuration?

GitHub exposes configuration at two scopes — repository and environment — across two categories: secrets (encrypted) and variables (plaintext).

TypeScopeEncryptedAccess syntax
Repository secretAll jobs in the repoYes${{ secrets.NAME }}
Environment secretJobs targeting that environmentYes${{ secrets.NAME }}
Repository variableAll jobs in the repoNo${{ vars.NAME }}
Environment variableJobs targeting that environmentNo${{ vars.NAME }}

Environment secrets and variables override repository-level values of the same name when a job specifies an environment. This lets you keep a default at the repo level and override per deployment target.

How do you access secrets in workflow YAML?

Secrets are available via the ${{ secrets.NAME }} expression context. You can use them in env blocks, step inputs, or with parameters.

yaml
name: Deploy
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to production
        env:
          API_KEY: ${{ secrets.API_KEY }}
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
        run: |
          echo "Deploying with API key (masked in logs)"
          ./deploy.sh

When a job declares environment: production, GitHub resolves secrets from the production environment first, falling back to repository secrets. Environment-level secrets can also require manual approval before the job runs.

How do env and vars differ?

The ${{ env.NAME }} context reads workflow-level or job-level environment variables set with the env key. The ${{ vars.NAME }} context reads configuration variables defined in the repository or environment settings UI.

yaml
env:
  NODE_ENV: production        # workflow-level env
  REGION: ${{ vars.REGION }}  # from Settings > Variables

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      CI: "true"              # job-level env
    steps:
      - name: Show values
        run: |
          echo "NODE_ENV=$NODE_ENV"             # shell env var
          echo "REGION=${{ vars.REGION }}"       # config variable
          echo "CI=${{ env.CI }}"                # workflow env context
          echo "DEPLOY_URL=${{ vars.DEPLOY_URL }}" # config variable

Key distinction: ${{ env.NAME }} reads from env: blocks you define in the YAML. ${{ vars.NAME }} reads from the GitHub UI settings. They are not interchangeable.

What does a Node.js workflow with secrets look like?

yaml
name: Node.js CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm

      - run: pnpm install --frozen-lockfile
      - run: pnpm test
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - run: pnpm deploy
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

What does a Python workflow with secrets look like?

yaml
name: Python CI
on: [push]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - run: pip install -r requirements.txt
      - run: pytest
        env:
          SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY }}
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

How do you pass secrets to a Docker build workflow?

yaml
name: Docker Build & Push
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v6
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:latest
          build-args: |
            APP_VERSION=${{ vars.APP_VERSION }}
            SENTRY_DSN=${{ vars.SENTRY_DSN }}

Notice how GITHUB_TOKEN is referenced as a secret even though you never create it manually. GitHub automatically provisions this token for every workflow run with permissions scoped to the repository.

What security guarantees do secrets provide?

  • Encrypted at rest — secrets are encrypted using a libsodium sealed box before they reach GitHub's servers.
  • Masked in logs — if a secret value appears in stdout or stderr, GitHub replaces it with ***. This works for the exact value only; substrings or transformed versions will not be masked.
  • Not available in fork PRs — pull requests from forks do not receive repository or environment secrets. This prevents malicious PRs from exfiltrating credentials.
  • GITHUB_TOKEN — automatically generated per workflow run with permissions scoped to the repo. Expires after the job completes. Prefer this over personal access tokens for repo operations.
  • Environment protection rules — environment secrets can require manual reviewer approval, branch restrictions, or a wait timer before they are exposed to a job.

For multi-line secrets (SSH keys, certificates), encode them to Base64 before storing. Use the Base64 encoder or the CLI:

bash
base64 -w 0 < private_key.pem | gh secret set SSH_PRIVATE_KEY

Then decode inside the workflow:

yaml
- name: Write SSH key
  run: echo "${{ secrets.SSH_PRIVATE_KEY }}" | base64 -d > ~/.ssh/id_rsa && chmod 600 ~/.ssh/id_rsa

What are the most common mistakes?

Using the wrong expression context:
yaml
# WRONG — reads from the env: block, not from GitHub settings
env:
  MY_SECRET: ${{ env.API_KEY }}

# RIGHT — reads the encrypted secret from GitHub
env:
  MY_SECRET: ${{ secrets.API_KEY }}

# WRONG — reads secrets context for a non-sensitive config value
region: ${{ secrets.AWS_REGION }}

# RIGHT — use vars for non-sensitive configuration
region: ${{ vars.AWS_REGION }}
Case sensitivity:

Secret and variable names are case-insensitive in the GitHub UI but must match exactly when referenced in YAML. By convention, always use UPPER_SNAKE_CASE. A mismatch like secrets.Api_Key vs the stored API_KEY resolves to an empty string with no error.

Secrets in conditionals:
yaml
# WRONG — never compare a secret directly; it leaks timing information
if: ${{ secrets.DEPLOY_KEY == 'expected-value' }}

# RIGHT — check if it exists (non-empty)
if: ${{ secrets.DEPLOY_KEY != '' }}
Echoing secrets for debugging:

GitHub masks the exact secret value, but echo with string manipulation (reversing, splitting, base64-encoding the secret) can bypass masking. Never echo secrets, even in debug mode. If one does leak, follow the rotation and history-scrubbing procedure in the environment variable security guide.

Forgetting the environment key:
yaml
# WRONG — environment secrets are NOT available without declaring the environment
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - run: echo ${{ secrets.PROD_DB_URL }}  # empty string

# RIGHT — declare the environment to access its secrets
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - run: echo ${{ secrets.PROD_DB_URL }}  # resolved

When should you use which type?

Use caseTypeWhy
API keys, tokens, passwordssecretsEncrypted and masked in logs
Per-environment credentialsenvironment secretsScoped + approval gates
Region, app name, feature flagsvarsPlaintext, visible, easy to audit
Build-time constants in YAMLenv:Defined inline, no UI setup needed
Repo-scoped operationsGITHUB_TOKENAuto-provisioned, scoped, ephemeral

Rule of thumb: if the value would be a problem in a public log, use a secret. If it is configuration that anyone on the team should be able to read and change, use a variable. Getting the values out of a teammate's local .env file and into GitHub in the first place is the step Actions does not cover — for that hand-off, see sharing .env files securely.

References

For a complete walkthrough of workflow syntax, triggers, and CI/CD pipelines, see the GitHub Actions guide.

Was this helpful?

Frequently Asked Questions

What is the difference between secrets and variables in GitHub Actions?

Secrets are encrypted and masked in logs — use them for API keys, tokens, and passwords. Variables (vars) are stored in plain text and visible in logs — use them for non-sensitive configuration like environment names, feature flags, and URLs.

Can GitHub Actions secrets be accessed in pull requests from forks?

No. For security, repository secrets are not available to workflows triggered by pull requests from forks. This prevents malicious PRs from exfiltrating secrets. Use the pull_request_target event if you need fork access with secrets, but be very careful about what code runs.

How do I use a multi-line secret in GitHub Actions?

Base64-encode the value before storing it as a secret, then decode it in your workflow: echo "${{ secrets.MY_CERT }}" | base64 --decode > cert.pem. This avoids issues with newlines and special characters.

How do I rotate a secret?

Update the value in Settings and re-run your workflow. There is no versioning — the new value takes effect immediately for all subsequent runs. For zero-downtime rotation, store both old and new values under different names during the transition.

Stay up to date

Get notified about new guides, tools, and cheatsheets.