Fix: GitHub Actions Secret Not Available — Environment Variable Empty in Workflow
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:
- 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.
- Secret not in the right scope — secrets defined at the organization level aren’t automatically available to all repositories; they must be granted access. Environment secrets are only available to jobs that reference that environment.
- Wrong secret name — secret names are case-sensitive.
MY_TOKENandmy_tokenare different secrets. - Secret value is empty — the secret was created but the value field was left blank, or it was created with a trailing newline that causes the value to be interpreted incorrectly.
- Accessing secrets in
ifconditions — GitHub masks secrets in logs but also prevents them from being used directly inifexpressions. - Pull request from a branch in the same repo — first-time contributors’ PRs 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 }}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 }}For related GitHub Actions issues, see Fix: GitHub Actions Matrix Strategy Not Working and Fix: GitHub Actions Docker Build Push.
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.