Reusable Workflow Secrets Not Passed: Find the Handoff
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.
Find the boundary where the secret disappears
Treat “the secret is missing” as a boundary diagnosis, not as one generic permissions problem. Start with the earliest signal:
| Observed signal | Boundary to inspect | Corrective action |
|---|---|---|
| The workflow is rejected before a runner starts | The caller passed a name that the called workflow did not declare under on.workflow_call.secrets | Declare the same secret ID in the called workflow, or use the supported secrets: inherit path deliberately. |
| The called job runs, but a presence check fails | The caller’s job-level secrets map is missing or maps the wrong name | Compare jobs.<job_id>.secrets in the caller with secrets.<secret_id> in the called workflow. |
| Workflow B receives the value, but nested workflow C does not | The B-to-C boundary did not forward the secret | Add another jobs.<job_id>.secrets mapping in B; nested workflows do not receive arbitrary secrets automatically. |
| The value changes after a job selects an environment | A same-named environment secret owns the called job’s value | Choose one owner or use distinct names; a called job’s environment can take precedence over the caller’s passed secret. |
| The value exists on normal runs but not on a fork pull request | Actions secrets are not passed to fork-triggered workflows, except for GITHUB_TOKEN | Use a no-secret validation path for untrusted fork code and require a trusted, reviewed path for deployment. |
| A workflow condition fails even though the secret exists | A secret was referenced directly in an if: conditional | Convert only the presence state to a boolean environment value, or use a non-secret gate; never put the secret value in the condition. |
For the last case, the called workflow can test presence without exposing the credential:
jobs: deploy: runs-on: ubuntu-latest env: HAS_DEPLOY_TOKEN: ${{ secrets.deploy_token != '' }} steps: - name: Stop when the handoff is unavailable if: ${{ env.HAS_DEPLOY_TOKEN != 'true' }} run: echo '::error::deploy_token is unavailable in this workflow run'This checks availability only. It does not make untrusted fork code safe to run with deployment credentials.
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.