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.
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 testThis 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.
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 publicConfigure 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).
| Type | Scope | Encrypted | Access syntax |
|---|---|---|---|
| Repository secret | All jobs in the repo | Yes | ${{ secrets.NAME }} |
| Environment secret | Jobs targeting that environment | Yes | ${{ secrets.NAME }} |
| Repository variable | All jobs in the repo | No | ${{ vars.NAME }} |
| Environment variable | Jobs targeting that environment | No | ${{ 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.
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.shWhen 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.
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 variableKey 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?
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?
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?
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:
base64 -w 0 < private_key.pem | gh secret set SSH_PRIVATE_KEYThen decode inside the workflow:
- name: Write SSH key
run: echo "${{ secrets.SSH_PRIVATE_KEY }}" | base64 -d > ~/.ssh/id_rsa && chmod 600 ~/.ssh/id_rsaWhat are the most common mistakes?
# 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 }}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.
# 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 != '' }}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.
# 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 }} # resolvedWhen should you use which type?
| Use case | Type | Why |
|---|---|---|
| API keys, tokens, passwords | secrets | Encrypted and masked in logs |
| Per-environment credentials | environment secrets | Scoped + approval gates |
| Region, app name, feature flags | vars | Plaintext, visible, easy to audit |
| Build-time constants in YAML | env: | Defined inline, no UI setup needed |
| Repo-scoped operations | GITHUB_TOKEN | Auto-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
- GitHub Docs — Encrypted secrets in Actions
- GitHub Docs — Configuration variables
- GitHub Docs — Environments and protection rules
- GitHub Docs —
GITHUB_TOKENreference - GitHub Docs — Passing secrets to a reusable workflow
For a complete walkthrough of workflow syntax, triggers, and CI/CD pipelines, see the GitHub Actions guide.