GitHub Actions Reusable Workflow Secrets Not Passed: A Practical Fix
If a secret is available in the caller workflow but empty in a reusable workflow, GitHub Actions did not pass it across the workflow boundary. Declare the secret contract in the called workflow, map the value directly or use secrets: inherit where it is allowed, and pass it again at every nested boundary.
Declare the secret in the reusable workflow
A reusable workflow is called through workflow_call. Named secrets make the contract visible and let GitHub validate the caller’s input:
name: Reusable deploy
on: workflow_call: inputs: environment: required: true type: string secrets: deploy_token: required: true
jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Deploy env: DEPLOY_TOKEN: ${{ secrets.deploy_token }} DEPLOY_ENVIRONMENT: ${{ inputs.environment }} run: ./scripts/deploy.sh "$DEPLOY_ENVIRONMENT"The caller passes a named secret on the job that uses the reusable workflow:
jobs: deploy: uses: ./.github/workflows/reusable-deploy.yml with: environment: staging secrets: deploy_token: ${{ secrets.DEPLOY_TOKEN }}The uses form is a job-level call, so the reusable workflow’s inputs and secrets belong beside with, secrets, and uses, not inside a step. The caller and called workflow also need compatible permissions; a called workflow cannot elevate the permissions it receives.
Choose direct mapping or secrets: inherit
Use direct mapping when the reusable workflow needs a small, documented set of secrets:
secrets: deploy_token: ${{ secrets.DEPLOY_TOKEN }} package_token: ${{ secrets.PACKAGE_TOKEN }}Use secrets: inherit when the caller and reusable workflow are inside the same organization or enterprise boundary and passing the available repository, organization, or enterprise secrets is intentional:
jobs: deploy: uses: org/platform/.github/workflows/reusable-deploy.yml@main secrets: inheritinherit is not a general cross-repository secret tunnel. For a reusable workflow outside the supported organization or enterprise boundary, map the required secrets explicitly or keep the secret-consuming job in the caller repository. Document the expected secret names even when using inherit; otherwise a rename can look like a permissions problem.
Forward secrets through nested reusable workflows
Secrets do not automatically travel through a chain. If workflow A calls workflow B and B calls workflow C, A must pass a secret to B and B must pass it to C:
# A: callerjobs: release: uses: ./.github/workflows/b.yml secrets: deploy_token: ${{ secrets.DEPLOY_TOKEN }}
# B: reusable workflow calling Cjobs: publish: uses: ./.github/workflows/c.yml secrets: deploy_token: ${{ secrets.deploy_token }}If B receives the secret but C does not, inspect the B-to-C edge first. The GitHub reusable workflow documentation also limits how far a nested chain can extend and requires secrets to be passed directly through each link.
Check environment-secret precedence
Environment secrets are a common source of a misleading “missing secret” result. Secrets defined under an environment cannot be passed through on.workflow_call. If a job in the called workflow declares an environment, a secret with the same name from that environment takes precedence over the secret the caller attempted to pass.
Decide which boundary owns the credential:
- Use a
workflow_callsecret for a value supplied by the caller. - Use an environment secret when deployment approval and environment protection should own the value.
- Do not give both boundaries the same name unless the precedence is deliberate and documented.
Debug the mapping without printing the value
Check presence, not the secret itself. A temporary diagnostic step can expose whether the called workflow received a value while keeping the value masked:
- name: Check deploy token wiring env: DEPLOY_TOKEN: ${{ secrets.deploy_token }} run: | if [ -z "$DEPLOY_TOKEN" ]; then echo '::error::deploy_token was not passed to the reusable workflow' exit 1 fiRemove the diagnostic after fixing the contract. Never print a secret, its length, or a transformed version of it as a debugging shortcut.
A quick checklist
- The called file uses
on.workflow_calland names the required inputs and secrets. - The caller maps each secret under the job-level
secretskey, or intentionally usessecrets: inherit. - Every nested caller forwards the secret again.
- No called job environment silently replaces the caller’s value.
- The called workflow’s
permissionsare sufficient for the operation. - Diagnostics test presence without exposing the credential.
If the same caller also uses typed manual inputs, see how GitHub Actions handles boolean workflow-dispatch inputs. For a later artifact failure, check the hidden-file behavior of upload-artifact separately; it is a different boundary from secret passing.
FAQ
Q: Does secrets: inherit work for any public reusable workflow?
A: No. inherit is intended for supported organization or enterprise boundaries. Use explicit mapping when the caller and called workflow do not share the required boundary.
Q: Why does a nested reusable workflow receive an empty secret?
A: The intermediate workflow probably received the secret but did not pass it in its own secrets mapping. Each A-to-B-to-C edge needs an explicit handoff.
Q: Can on.workflow_call receive an environment secret?
A: No. Environment secrets are not passed through the workflow_call interface. If the called job selects an environment, its environment secret can take precedence over a caller-provided secret with the same name.
References:
Report a typo or broken link, or suggest a related topic.