Skip to content

Fix: GitHub Actions Secret Not Available — Environment Variable Empty in Workflow

FixDevs ·

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: token

Or 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_TOKEN and my_token are 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 if conditions — GitHub masks secrets in logs but also prevents them from being used directly in if expressions.
  • 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 production

Secret naming rules:

  • Case-sensitive: MY_TOKENmy_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 empty

Verify 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 PRs

Option 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 available

Option 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 accessible

Environment 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 bug

Check which environments exist and their secrets:

gh api repos/myorg/myrepo/environments --jq '.[].name'
gh secret list --repo myorg/myrepo --env production

Fix 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 access

To grant repository access to an organization secret:

  1. Go to Organization SettingsSecrets and variablesActions
  2. Click the secret name
  3. 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 UI

Strip 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_TOKEN

Update 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 available

Common 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles