Your CI pipeline was working fine. Then someone adds a new API integration, sets up the secret in GitHub, references it in the workflow — and the job fails. The API call returns 401 Unauthorized, or worse, the secret value is just empty and the step silently does the wrong thing.
Secrets in CI are a special kind of debugging challenge: you can't print them to verify they're correct (and you shouldn't), so the normal "add a console.log" approach doesn't work. You need a different diagnostic strategy.
The Most Common Cause: Fork PRs Don't Get Secrets
This catches every team exactly once, and it's by design.
When a contributor opens a pull request from a fork of your repository, GitHub Actions does not expose your repository secrets to the workflow. This is a critical security feature — if it didn't work this way, anyone could fork your repo, add echo ${{ secrets.AWS_KEY }} to the workflow, and steal your credentials.
But the error message doesn't say "secrets are unavailable because this is a fork PR." The secret variable simply resolves to an empty string, and whatever step depends on it fails downstream with a confusing, unrelated error.
How to handle this:
- Use
pull_request_targetinstead ofpull_requestif you need secrets on fork PRs (but understand the security implications — the workflow runs in the context of the base branch) - Or, split your workflow: run tests (no secrets needed) on
pull_request, run integration tests (secrets needed) onpull_request_targetwith explicit checkout of the PR commit - Or, skip secret-dependent steps on forks:
- name: Integration test
if: github.event.pull_request.head.repo.full_name == github.repository
run: npm run test:integration
env:
API_KEY: ${{ secrets.API_KEY }}
Secret Scope Issues
GitHub has three levels of secret scope, and mixing them up causes silent failures:
| Scope | Accessible By | Common Mistake |
|---|---|---|
| Repository secrets | All workflows in that repo | None — this is the default and usually correct |
| Environment secrets | Only jobs that specify environment: | Forgetting to add environment: production to the job |
| Organization secrets | Repos granted access via policy | New repo isn't added to the org secret's access list |
The environment trap: If you store a secret in the "production" environment but your job doesn't declare environment: production, the secret simply doesn't exist from the job's perspective:
# Broken: secret is in "production" environment but job doesn't specify it
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: curl -H "Authorization: Bearer ${{ secrets.DEPLOY_TOKEN }}" ...
# Fixed: declare the environment
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # ← This makes environment secrets available
steps:
- run: curl -H "Authorization: Bearer ${{ secrets.DEPLOY_TOKEN }}" ...
Naming Mismatches
Simpler than it sounds, and surprisingly common:
- Case sensitivity — GitHub secret names are case-insensitive, but the reference in your workflow is case-sensitive in some contexts.
secrets.api_keyvssecrets.API_KEY— use the exact name you set - Typos —
secrets.DATABSE_URL(note the missing A) won't throw an error. It just returns an empty string. There's no "secret not found" warning - GitLab difference — GitLab CI/CD variables are case-sensitive and use
$VARIABLE_NAMEsyntax instead of${{ secrets.NAME }}
Safe Verification Without Leaking
Never print a secret value. Instead, verify that a secret exists and is non-empty without revealing its contents:
# GitHub Actions — check existence
- name: Verify secrets
run: |
if [ -z "$API_KEY" ]; then
echo "::error::API_KEY secret is not set or empty"
exit 1
fi
echo "API_KEY is set (length: ${#API_KEY})"
env:
API_KEY: ${{ secrets.API_KEY }}
# Safer: just check the expression directly
- name: Check secret
if: secrets.API_KEY == ''
run: |
echo "::error::API_KEY is not configured"
exit 1
For GitLab CI:
# .gitlab-ci.yml
verify_secrets:
stage: .pre
script:
- |
if [ -z "$DEPLOY_TOKEN" ]; then
echo "DEPLOY_TOKEN is not set"
exit 1
fi
echo "DEPLOY_TOKEN is set (length: ${#DEPLOY_TOKEN})"
Expired or Rotated Credentials
Secrets that worked last month might be expired now:
- API tokens — many services issue tokens with 90-day expiry by default
- OAuth tokens — refresh tokens can expire if unused for too long
- AWS keys — security-conscious orgs rotate keys regularly but may not update CI
Prevention:
- Use OIDC tokens instead of static secrets where possible. GitHub Actions supports OIDC natively for AWS, GCP, and Azure — no stored credentials needed
- Set calendar reminders for credential rotation dates
- Add a
.prestage that validates credentials before running expensive jobs
The Complete Checklist
- ☐ Is this a fork PR? Secrets aren't available on fork PRs by default
- ☐ Is the secret name spelled correctly? (No typos, correct case)
- ☐ Is the secret at the right scope? (Repo vs environment vs org)
- ☐ Does the job declare the correct
environment:? - ☐ Is the secret actually set? (Check Settings → Secrets)
- ☐ Has the credential expired or been rotated?
- ☐ For GitLab: is the variable protected? Protected variables only work on protected branches
- ☐ For GitLab: is the variable masked? Masked variables must match a regex pattern
Since you can't (and shouldn't) print secret values in logs, this is one of the trickier failure classes to auto-diagnose. Daxtack flags the pattern — job fails right after a step that references a secret — without ever touching the secret value itself.