What this usually means
This is not a misconfiguration or a bug in your workflow—it's a security guarantee enforced by GitHub Actions. When a workflow is triggered by `pull_request_target` (the correct event for safe secret access) vs `pull_request`, the secret availability differs. For `pull_request` events from forks, GitHub deliberately masks all secrets to prevent a malicious PR from exfiltrating them via a modified workflow. The fix involves either using `pull_request_target` with careful checkout, or moving secret-dependent steps to run only on non-fork events like `push` or `workflow_dispatch`.
The first ten minutes — establish facts before touching code.
- 1Check the triggering event: Look at the workflow run's 'Event' field. If it's `pull_request` from a fork, secrets are blocked.
- 2Add a step to print the secret length: `echo "Secret length: ${#MY_SECRET}"` — if it prints 0, the secret is empty.
- 3Inspect the workflow file: See if it uses `pull_request` or `pull_request_target`. The former blocks secrets on forks.
- 4Run the same workflow on a branch within the base repo: If it works there, the fork origin is the culprit.
- 5Check GitHub's documentation on auto-cancellation: Sometimes secret-dependent jobs are skipped with a 'cancelled' status due to missing secrets.
The specific files, logs, configs, and dashboards that usually own this bug.
- search`.github/workflows/*.yml` — the workflow file that defines the event trigger and secret usage
- searchGitHub Actions run logs: `https://github.com/<owner>/<repo>/actions/runs/<run_id>` — examine the 'Set up job' section for secret availability
- searchRepository Settings > Secrets and variables > Actions — verify the secret exists and is spelled correctly
- searchPull request page: Check if the PR is from a fork (look for 'Forked from' label)
- searchGitHub status page or documentation: `docs.github.com/en/actions/security-guides/security-hardening-for-github-actions`
Practical causes, not theory. These are the things you will actually find.
- warningWorkflow triggered by `pull_request` (not `pull_request_target`) from a fork — secrets are blocked by design
- warningDeveloper assumes secrets are available in all contexts; no conditional logic to skip secret-dependent steps on forks
- warningSecrets defined but not accessible due to environment protection rules (e.g., required reviewers on environments)
- warningTypo in secret name: `MY_SECRET` vs `MY_SECRET_` — the secret resolves to empty string silently
- warningUsing `secrets` context inside a `run` step without proper shell escaping (secret appears as `***` but value is actually present)
- warningWorkflow runs on `pull_request` but the `base` repo doesn't have the secret defined (secret propagation requires explicit definition)
Concrete fix directions. Pick the one that matches your root cause.
- buildSwitch to `pull_request_target` event with a safe checkout strategy: check out the PR code only after using `actions/checkout@v4` with `ref: ${{ github.event.pull_request.head.sha }}`
- buildAdd a condition to skip secret-dependent steps on fork PRs: `if: github.event.pull_request.head.repo.fork == false`
- buildUse a separate workflow for fork contributions that runs on `issue_comment` or `workflow_dispatch` with manual approval
- buildStore non-sensitive configuration in repository variables (not secrets) and use them publicly
- buildImplement a token exchange: fork PRs can use a temporary token from a third-party service (e.g., OIDC) to obtain secrets at runtime
A fix you cannot prove is a guess. Close the loop.
- verifiedTrigger the fixed workflow from a fork PR and check the logs: secret-dependent steps are skipped or use alternative paths
- verifiedRun the same workflow from a branch in the base repo: it should still work with secrets
- verifiedAdd a debug step: `echo "Event: ${{ github.event_name }}, Fork: ${{ github.event.pull_request.head.repo.fork }}"` — confirm fork status
- verifiedCheck that `pull_request_target` workflow runs in the context of the base repository, not the fork (it will show as triggered by the base repo in logs)
- verifiedVerify that `actions/checkout` with `ref: ${{ github.event.pull_request.head.sha }}` checks out the correct commit
Things that make this bug worse or harder to find.
- warningNever use `pull_request` with secrets — it's a security hole; GitHub blocks it but some think they can override
- warningDon't blindly switch to `pull_request_target` without checking out the PR code correctly — you'll run the base branch's workflow, not the PR's
- warningAvoid echoing secrets in logs (e.g., `echo $SECRET`) — it's a security violation even if masked
- warningDo not store secrets in repository variables — they are not encrypted and visible to all
- warningDon't assume that using `env` context will bypass the security boundary — secrets are still empty
Fork PR Deployment Fails: Missing Docker Registry Password
Timeline
- 09:15External contributor submits PR #47 from fork to main repo
- 09:17CI workflow triggers on `pull_request` event
- 09:18Build step succeeds, login step fails: 'Error: unauthorized: incorrect username or password'
- 09:20I check logs: `docker login` uses `DOCKER_PASSWORD` secret, but it's empty
- 09:22I verify secret exists in repo settings — it's defined correctly
- 09:25I trigger same workflow on a branch in base repo — it works
- 09:30I read GitHub docs: `pull_request` from fork does not have access to secrets
- 09:35I change event to `pull_request_target` and update checkout to use `ref: ${{ github.event.pull_request.head.sha }}`
- 09:40I add condition: `if: github.event.pull_request.head.repo.fork == false` for the deploy job
- 09:45Fork contributor retriggers workflow — build passes, deploy is skipped, no failure
At 9:15, a community contributor submitted a PR adding a new endpoint. Our CI pipeline builds a Docker image and pushes it to a private registry. The workflow had been working fine for internal branches. The contributor's PR triggered the workflow, but the Docker login step failed with an 'unauthorized' error. I first suspected a secret rotation issue, so I checked the secret in GitHub settings — it was there, unchanged.
I ran the workflow manually on a branch in the base repo, and it passed. That's when I realized the fork was the difference. I remembered reading about GitHub's security model: secrets are not exposed to pull requests from forks. The `pull_request` event deliberately masks secrets to prevent exfiltration. The solution was to change the trigger to `pull_request_target`, which runs in the context of the base repository and has access to secrets. However, I had to be careful to check out the PR's code, not the base branch's code, to avoid running the base workflow file.
I updated the workflow to use `pull_request_target`, set the checkout action to use the PR's head SHA, and added a conditional to skip the deploy step on fork PRs (since we don't want to push untrusted images). After the contributor re-triggered the workflow, the build passed, and the deploy step was skipped gracefully. The lesson: secrets are a trust boundary; you must design workflows to handle fork contributions safely.
Root cause
Workflow used `pull_request` event, which blocks all secrets for fork PRs due to GitHub's security model.
The fix
Changed event to `pull_request_target`, updated checkout to use PR head SHA, and added a condition to skip deploy on fork PRs.
The lesson
Always design CI/CD pipelines to gracefully handle fork contributions by using `pull_request_target` or conditional step execution, never assume secrets are available.
GitHub Actions enforces a critical security boundary: workflows triggered by `pull_request` events from forked repositories run with a read-only token and have no access to repository secrets. This prevents a malicious actor from modifying the workflow file to exfiltrate secrets (e.g., by adding a step that sends them to an external server). The `pull_request` event is designed for untrusted code validation.
In contrast, the `pull_request_target` event runs in the context of the base repository, with full access to secrets and a write token. However, it executes the workflow file from the base branch, not the fork. This means you must explicitly check out the PR's code if you need to run tests on the contributor's changes. The trade-off is security: you trust the base workflow, but you must be cautious about what you do with the PR code.
When using `pull_request_target`, the default checkout checks out the base branch. To run tests on the fork's code, you must check out the PR head SHA explicitly: `actions/checkout@v4` with `ref: ${{ github.event.pull_request.head.sha }}`. This ensures you're running on the contributor's code while still having access to secrets.
However, be aware that the workflow file itself is from the base branch. If the fork PR modifies the workflow file, those changes are ignored. This is intentional to prevent malicious workflow modifications. To run a modified workflow from a fork, you'd need to use `pull_request` (without secrets) or a manual approval process.
A common pattern is to add conditions to steps or jobs that require secrets. For example: `if: github.event.pull_request.head.repo.fork == false`. This ensures that secret-dependent actions (like deploying or publishing) only run when the PR is from a branch within the base repo. For fork PRs, those steps are skipped, but the rest of the CI (build, test) can still run.
Alternatively, you can use two separate workflows: one for `push` events (full access) and one for `pull_request` (limited). Or use a single workflow with matrix strategy to handle both cases. The key is to never fail the workflow due to missing secrets; instead, skip gracefully.
To debug secret availability, add a step like: `echo "Secret length: ${#MY_SECRET}"`. If the secret is missing, the length will be 0. Note: never echo the actual secret value — it will be masked in logs, but it's still a bad practice and may trigger security warnings.
Another technique: use `if: env.MY_SECRET != ''` to conditionally run steps. You can also set a fallback value: `MY_SECRET: ${{ secrets.MY_SECRET || 'default' }}`. If the secret is missing, the default value is used, allowing the step to run (but with limited functionality).
For advanced use cases, you can use OpenID Connect (OIDC) to obtain temporary credentials from a cloud provider (e.g., AWS, Azure) without storing long-lived secrets. This works on fork PRs because OIDC tokens are issued based on the workflow run's identity, not repository secrets. The workflow must be configured to trust the OIDC provider.
Another approach is to use a third-party service like 'act' (local runner) or a self-hosted runner that has access to secrets. However, self-hosted runners for fork PRs introduce their own security risks. The simplest and most secure approach is to design workflows that don't require secrets for fork contributions.
Frequently asked questions
Can I use `pull_request` with `repository_dispatch` to bypass secret restrictions?
No. `repository_dispatch` events are not triggered by PRs; they require an external API call. You could set up a workflow that listens for `repository_dispatch` and passes the PR head ref, but that's complex and still doesn't solve the secret issue because the dispatch event runs in the base repo context. The standard approach is `pull_request_target`.
Why does my workflow pass on internal PRs but fail on fork PRs?
Because internal PRs (from branches in the same repository) are not considered 'fork' PRs. GitHub treats them as trusted and allows secret access. Fork PRs are from external repositories and are untrusted by default. Check the `head.repo.fork` property to differentiate.
What if I use `pull_request_target` but still can't access secrets?
Ensure the secret is defined in the base repository (not just the fork). Also check environment protection rules: if the job uses an environment that requires approval, secrets may be unavailable until approved. Finally, verify the secret name is correct and that the workflow file is on the default branch (since `pull_request_target` uses the base branch's workflow).
Is it safe to check out the PR head in a `pull_request_target` workflow?
It's safe if you don't run untrusted code with elevated privileges. The checkout action itself is safe. However, avoid running build scripts or tests that could execute malicious code. A best practice is to limit secret-dependent steps to only those that are necessary and to run tests in a sandboxed environment.
Can I use `GITHUB_TOKEN` to access secrets?
No. `GITHUB_TOKEN` is a temporary token for API access, not for reading secrets. Secrets are accessed via the `secrets` context. The `GITHUB_TOKEN` is also restricted on fork PRs (read-only), but that's separate from secret availability.