Fix: GitHub Actions Secret Not Available — Environment Variable Empty in Workflow
Part of: Docker, DevOps & Infrastructure
Quick Answer
How to fix GitHub Actions secrets that appear empty or undefined in workflows — secret scope, fork PR restrictions, environment protection rules, secret names, and OIDC alternatives.
The Problem
A GitHub Actions secret is defined in repository settings but the workflow step sees an empty value:
- name: Deploy
run: |
echo "Token length: ${#MY_API_TOKEN}" # Outputs: Token length: 0
curl -H "Authorization: Bearer $MY_API_TOKEN" https://api.example.com/deploy
# curl fails with 401 — token is empty
env:
MY_API_TOKEN: ${{ secrets.MY_API_TOKEN }}Or the workflow fails with an error referencing the secret:
Error: Input required and not supplied: tokenOr secrets work in the main branch but not in pull requests from forks:
Warning: Skip output MY_SECRET, corporationsSecret, secret is not found.Or a required environment protection rule blocks secret access:
Error: Required environment 'production' is not deployed yet or you don't have permission to access it.Why This Happens
GitHub Actions secrets have several scope and access restrictions that aren’t immediately obvious, and most of them exist for good security reasons. Understanding which restriction is hitting you is the entire game.
Fork pull requests don’t get secrets by default. PRs from forked repositories run workflows without access to the parent repository’s secrets — this is a security measure to prevent malicious PRs from exfiltrating secrets via a one-line workflow modification. The trade-off is that legitimate fork contributors see empty values where the workflow expects credentials. Organization-level secrets aren’t automatically available to all repositories either; they must be explicitly granted access. Environment secrets are only available to jobs that reference that environment by name in the job definition.
Secret names are case-sensitive: MY_TOKEN and my_token are different secrets, and a typo in the YAML reference silently returns an empty string. Secret values can be empty if the secret was created but the value field was left blank, or if it was set with a trailing newline that breaks the authentication header. Accessing secrets in if conditions doesn’t work — GitHub masks secrets in logs but also prevents them from being evaluated in conditional expressions. For pull requests from a branch in the same repo, first-time contributors’ PRs may require approval before secrets are available, depending on repository settings.
Fix 1: Verify Secret Scope and Name
Check that the secret exists at the right scope and with the exact name:
# List secrets available to a repository (names only — values are never shown)
gh secret list --repo myorg/myrepo
# List organization secrets
gh secret list --org myorg
# List environment secrets
gh secret list --repo myorg/myrepo --env productionSecret naming rules:
- Case-sensitive:
MY_TOKEN≠my_token - No spaces or special characters (underscores allowed)
- Cannot start with
GITHUB_(reserved prefix) - Cannot start with a number
Access the secret correctly in YAML:
# CORRECT — reference as ${{ secrets.SECRET_NAME }}
env:
API_TOKEN: ${{ secrets.MY_API_TOKEN }}
# WRONG — missing 'secrets.' context
env:
API_TOKEN: ${{ MY_API_TOKEN }} # References undefined variable
# WRONG — wrong case
env:
API_TOKEN: ${{ secrets.my_api_token }} # If secret is named MY_API_TOKEN, this is emptyVerify the secret has a value (without revealing it):
- name: Check secret is set
run: |
if [ -z "${{ secrets.MY_API_TOKEN }}" ]; then
echo "::error::MY_API_TOKEN secret is not set or is empty"
exit 1
fi
echo "Secret is set (length: ${#MY_API_TOKEN})"
env:
MY_API_TOKEN: ${{ secrets.MY_API_TOKEN }}Fix 2: Fix Fork Pull Request Secret Access
Workflows triggered by pull requests from forks don’t have access to secrets by default:
# This workflow runs on pull_request events
# Forks can't read secrets — MY_TOKEN will be empty for fork PRs
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: curl -H "Authorization: ${{ secrets.MY_TOKEN }}" https://api.example.com
# Works for branch PRs, fails (empty token) for fork PRsOption 1: Use pull_request_target for trusted fork workflows:
# pull_request_target runs in the context of the BASE repository — has access to secrets
# WARNING: pull_request_target runs the workflow from the BASE branch, not the PR branch
# SECURITY RISK: Don't check out and run code from the PR without careful review
on:
pull_request_target:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # Check out PR code
# Only do this if the workflow doesn't run untrusted code
- run: echo "${{ secrets.MY_TOKEN }}" # Now availableOption 2: Require approval for first-time contributors:
In Repository Settings → Actions → General, set “Fork pull request workflows from outside collaborators” to “Require approval for first-time contributors.” This prompts maintainers to approve before secrets are exposed.
Option 3: Separate secret-using jobs into a different workflow:
# Test workflow — triggered by pull_request (no secrets needed)
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test # No secrets needed for testing
---
# Deploy workflow — only runs after merge, has access to secrets
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
env:
API_TOKEN: ${{ secrets.DEPLOY_TOKEN }}Fix 3: Fix Environment Secret Scope
Environment secrets are only accessible to jobs that explicitly reference the environment:
# WRONG — job doesn't reference the environment, can't access environment secrets
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
env:
API_KEY: ${{ secrets.PRODUCTION_API_KEY }} # Empty — no environment declared# CORRECT — declare the environment at the job level
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # Must reference the environment
steps:
- run: ./deploy.sh
env:
API_KEY: ${{ secrets.PRODUCTION_API_KEY }} # Now accessibleEnvironment protection rules — if the production environment requires approval, the job waits for a reviewer. The job can’t proceed (and secrets aren’t available) until approved:
# Required reviewers set in Environment settings block the job here
jobs:
deploy:
environment:
name: production
url: https://myapp.example.com
runs-on: ubuntu-latest
# This job waits for approval before running — expected behavior, not a bugCheck which environments exist and their secrets:
gh api repos/myorg/myrepo/environments --jq '.[].name'
gh secret list --repo myorg/myrepo --env productionFix 4: Fix Organization Secret Repository Access
Organization-level secrets must be explicitly granted to repositories:
# Workflow uses an organization secret
env:
ORG_DEPLOY_KEY: ${{ secrets.ORG_DEPLOY_KEY }} # Empty if repo not granted accessTo grant repository access to an organization secret:
- Go to Organization Settings → Secrets and variables → Actions
- Click the secret name
- Under “Repository access,” select the repositories or choose “All repositories”
Or use the GitHub CLI:
# Grant a specific repo access to an org secret
gh secret set ORG_DEPLOY_KEY \
--org myorg \
--repos myrepo1,myrepo2 \
--body "$(cat deploy-key.pem)"
# Grant all repos access
gh secret set ORG_DEPLOY_KEY \
--org myorg \
--visibility all \
--body "the-secret-value"Fix 5: Set and Update Secrets Correctly
Secrets with trailing whitespace or newlines cause authentication failures even when the “value” appears correct:
# Set a secret from a file (avoids shell escaping issues)
gh secret set MY_API_TOKEN < token.txt
# Set from a variable (be careful with newlines)
gh secret set MY_API_TOKEN --body "$TOKEN"
# Set multiline secret (e.g., a private key)
gh secret set SSH_PRIVATE_KEY < ~/.ssh/id_rsa
# Verify using the repo's Actions settings page — check that the secret shows
# a masked value (not empty) in the UIStrip trailing newlines when setting secrets from files:
# Some editors add trailing newlines — strip them
tr -d '\n' < token.txt | gh secret set MY_API_TOKEN
# Or use printf instead of echo (echo adds newline, printf doesn't by default)
printf '%s' "$TOKEN" | gh secret set MY_API_TOKENUpdate a secret that may have an incorrect value:
# Delete and recreate
gh secret delete MY_API_TOKEN --repo myorg/myrepo
gh secret set MY_API_TOKEN --repo myorg/myrepo --body "new-value"Fix 6: Use OIDC Instead of Long-Lived Secrets
For cloud deployments (AWS, GCP, Azure), use OpenID Connect (OIDC) to avoid storing long-lived credentials as secrets entirely:
# AWS — no AWS_SECRET_ACCESS_KEY needed
permissions:
id-token: write # Required for OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
aws-region: us-east-1
# No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY secrets needed
- name: Deploy to S3
run: aws s3 sync ./dist s3://my-bucket/AWS IAM role trust policy for GitHub Actions OIDC:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:*"
}
}
}
]
}OIDC tokens are short-lived (valid for the duration of the workflow job), so there’s no secret to rotate or accidentally expose.
Fix 7: Debug Secret Availability
GitHub masks secret values in logs (replaces with ***), which can make debugging difficult:
- name: Debug secret availability
run: |
# Check if the secret is set (without revealing it)
echo "Secret is set: ${{ secrets.MY_TOKEN != '' }}"
# Check length (safe — doesn't reveal the value)
echo "Secret length: ${#MY_TOKEN}"
# Test the secret actually works by making an authenticated call
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $MY_TOKEN" \
https://api.example.com/verify)
echo "Auth response code: $HTTP_CODE"
if [ "$HTTP_CODE" != "200" ]; then
echo "::error::Authentication failed — check secret value"
exit 1
fi
env:
MY_TOKEN: ${{ secrets.MY_TOKEN }}Check the workflow’s available contexts:
- name: Dump secrets context (names only, not values)
run: echo '${{ toJSON(secrets) }}'
# GitHub replaces all secret values with *** in logs
# But you can see which secret names are availableCommon mistake: using secrets in if conditions:
# WRONG — secrets can't be used directly in if expressions
- name: Deploy
if: ${{ secrets.DEPLOY_TOKEN != '' }} # Always evaluates to false
run: ./deploy.sh
# CORRECT — check in a step using env
- name: Check token
id: check
run: |
if [ -n "$TOKEN" ]; then
echo "has_token=true" >> $GITHUB_OUTPUT
fi
env:
TOKEN: ${{ secrets.DEPLOY_TOKEN }}
- name: Deploy
if: steps.check.outputs.has_token == 'true'
run: ./deploy.sh
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}Production Incident Patterns
When a deploy fails because a secret is undefined, deploys are frozen for everyone on the team until the scope issue is resolved. The blast radius is the entire delivery pipeline. The triage flow matters because the symptom — empty environment variable — has at least four different root causes that look identical in the logs.
Scenario: reusable workflow strips secrets without warning. A team refactors three near-duplicate deploy workflows into a single reusable workflow called via uses: ./.github/workflows/deploy.yml. The first call from main succeeds because the caller has secrets: inherit. Months later, a new repo adopts the reusable workflow but copies only the uses: block without the secrets: inherit line. Deploys fail with Input required and not supplied: token. No code in the reusable workflow changed, but every consumer must explicitly pass secrets — by name or with inherit — for the called workflow to see them.
Scenario: fork PR runs the deploy job. A maintainer accepts a doc-only PR from an external contributor. The PR triggers the standard CI workflow, which includes a deploy job behind if: github.ref == 'refs/heads/main'. Because the deploy job evaluates secrets at parse time, the workflow run is flagged as needing approval. The maintainer approves without re-reading the diff. Now the deploy job runs in a fork context, secrets are empty, and the deploy fails midway, leaving the production environment in a half-deployed state. The fix is to split deploys into a push workflow that fork PRs cannot trigger, never pull_request_target.
Triage checklist (run in order). First, confirm the secret name and case match exactly between the YAML and the settings UI. Second, run gh secret list at the relevant scope (repo, org, environment) to verify the secret exists where the job is reading from. Third, check if the workflow is running in a fork context — fork PRs strip secrets. Fourth, if the secret is on an environment, confirm the job has environment: <name> declared. Fifth, for reusable workflows, confirm secrets: inherit or explicit pass-through is in place. Monitor failed deploy rate as a leading indicator — a sudden spike usually traces to a single config change that broke secret propagation across many workflows.
Still Not Working?
Secret not showing in the UI after creation — after creating a secret, you must re-save (update) it to ensure it’s stored. Refresh the Secrets page to confirm the secret shows a value (displayed as a dot pattern, not blank).
Secret rotation needed — if the secret was valid when created but now returns auth errors, the underlying credential may have expired or been revoked. Regenerate the token in the third-party service and update the secret.
GITHUB_TOKEN limitations — the automatically provided GITHUB_TOKEN can’t trigger other workflows (to prevent infinite loops) and can’t push to protected branches without additional permissions. For cross-workflow triggers, use a personal access token or GitHub App token.
Reusable workflows and secrets — secrets from the calling workflow are not automatically available to reusable workflows. Pass them explicitly using secrets: inherit or list them individually:
# Caller
jobs:
call-deploy:
uses: ./.github/workflows/deploy.yml
secrets: inherit # Pass all caller's secrets to the reusable workflow
# Or: secrets:
# DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}Composite action drops secrets — composite actions (those defined in action.yml) do not inherit secrets from the calling workflow. You must pass secrets as inputs and reference them inside the composite as ${{ inputs.token }}. The runner refuses to expose the secrets context inside a composite action’s steps.
Secret with embedded special characters — if a secret contains characters that the shell interprets (backticks, $, double quotes), passing it through run: blocks can corrupt the value. Always assign secrets through the env: map at the step level, then reference the env var with proper quoting ("$MY_TOKEN") inside the shell command.
For related GitHub Actions issues, see Fix: GitHub Actions Matrix Strategy Not Working, Fix: GitHub Actions Docker Build Push, Fix: GitHub Actions Reusable Workflow, and Fix: GitHub Actions env Var Between Steps.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: GitHub Actions Artifacts Not Working — Upload Fails, Download Empty, or Artifact Not Found
How to fix GitHub Actions artifact issues — upload-artifact path patterns, download-artifact across jobs, retention days, artifact name conflicts, and the v3 to v4 migration.
Fix: GitHub Actions Matrix Strategy Not Working — Jobs Not Running or Failing
How to fix GitHub Actions matrix strategy issues — matrix expansion, include/exclude patterns, failing fast, matrix variable access, and dependent jobs with matrix outputs.
Fix: GitHub Actions Docker Build and Push Failing
How to fix GitHub Actions Docker build and push errors — registry authentication, image tagging, layer caching, multi-platform builds, and GHCR vs Docker Hub setup.
Fix: GitHub Actions Environment Variables Not Available Between Steps
How to fix GitHub Actions env vars and outputs not persisting between steps — GITHUB_ENV, GITHUB_OUTPUT, job outputs, and why echo >> $GITHUB_ENV is required.