GitHub Actions Environment Secrets Empty Without environment: on the Job
A secret defined at the environment level is empty in your job because the job did not declare environment:, so only repository and org secrets are available.
What this error means
secrets.MY_SECRET resolves to an empty string even though it is set under an environment. Moving the secret to repository scope, or adding environment: to the job, makes it appear.
jobs:
deploy:
runs-on: ubuntu-latest
# no environment: declared, so the "production" environment secret is empty
steps:
- run: echo "len=${#TOKEN}"
env:
TOKEN: ${{ secrets.PROD_TOKEN }}Diagnose it: what token do you actually have?
Permission failures in Actions are almost never about your repository settings alone. Three things combine: the default GITHUB_TOKEN permission set for the repo or organization, the permissions: block in the workflow, and whether the event is a fork pull request, which downgrades the token to read-only regardless of everything else.
- name: Show the token scopes actually granted
run: |
curl -sI -H "Authorization: Bearer $GITHUB_TOKEN" \
https://api.github.com/ | grep -i "^x-oauth-scopes\|^x-accepted"
echo "event: ${{ github.event_name }}"
echo "fork PR: ${{ github.event.pull_request.head.repo.fork }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}Common causes
Job did not declare the environment
Environment-scoped secrets only load for jobs that reference that environment. Without environment: production, the production secrets are not in scope.
Secret stored at the wrong scope
A secret meant to be broadly available was stored only on one environment, so jobs without that environment cannot read it.
How to fix it
Declare the environment on the job
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- run: ./deploy.sh
env:
TOKEN: ${{ secrets.PROD_TOKEN }}Choose the right secret scope
- Use environment secrets for values gated by environment protection.
- Use repository or org secrets for values every job needs.
- Avoid duplicating the same secret across scopes unless intentional.
Grant the narrowest permission that works
Declaring a permissions: block switches the job from the repository default to exactly what you list, so an incomplete block is a common cause of a new failure right after someone tightened security. List every scope the job needs, not just the one that failed.
permissions:
contents: read # checkout
packages: write # push to GHCR
id-token: write # OIDC to a cloud provider
pull-requests: write # comment on or label a PR
checks: write # publish check runsHow to prevent it
- Declare environment: on any job that reads environment-scoped secrets.
- Pick secret scope deliberately: environment vs repository vs org.
- Confirm a secret length in a debug step when wiring up a new deploy.