Secrets in GitHub Actions
Introduced in: Module 05 · GitHub Actions & CI/CD
Your CI/CD pipeline has elevated access to your repository, your cloud provider, and your deployment targets. That makes it a high-value target. A compromised workflow can exfiltrate every secret you’ve stored, push malicious code, or deploy to production.
This page covers how to keep your pipeline secure without making it impossible to work with.
How GitHub Secrets Work
GitHub Actions secrets are encrypted at rest using libsodium and only decrypted inside the runner environment at the moment they’re needed. They are:
- Never printed in logs — GitHub automatically redacts values that match stored secrets
- Not accessible to forked repositories — by default, secrets are not passed to workflows triggered by pull requests from forks
- Scoped — secrets can be stored at the repository, environment, or organisation level
Setting a Secret
- Go to Settings → Secrets and variables → Actions
- Click New repository secret
- Enter a name (convention:
SCREAMING_SNAKE_CASE) and value - Click Add secret
Using a Secret in a Workflow
steps: - name: Deploy to production run: ./deploy.sh env: API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} DB_URL: ${{ secrets.DATABASE_URL }}The secret value is injected as an environment variable only for that step. It’s not in the YAML file — ${{ secrets.NAME }} is a reference, not the value itself.
The GITHUB_TOKEN
Every workflow run automatically receives a GITHUB_TOKEN — a short-lived token that grants access to the repository’s GitHub API. You don’t create it; it’s provisioned by GitHub at the start of each run and expires when the run completes.
The GITHUB_TOKEN is used by actions that need to interact with GitHub: creating releases, posting comments on PRs, uploading artifacts, deploying to Pages.
Scoping GITHUB_TOKEN Permissions
By default, GITHUB_TOKEN has read access to most resources and write access to several. This is more permissive than most workflows need.
Best practice: Set the most restrictive permissions at the workflow level, then grant specific permissions per job.
# Top of workflow file — deny everything by defaultpermissions: contents: read
jobs: build: runs-on: ubuntu-latest # Inherits read-only from top level — no additional permissions needed
deploy: runs-on: ubuntu-latest permissions: contents: read pages: write # Only this job needs Pages write access id-token: write # Only this job needs OIDC token for attestationWhy this matters: If a workflow step is compromised (for example, a malicious action in your dependency graph runs arbitrary code), it can only use the permissions you’ve granted. A workflow with contents: write globally could push malicious commits. A workflow with contents: read cannot.
The A2A project’s pages.yml demonstrates this pattern — pages: write and id-token: write are granted only to the deploy job, not the build job.
Workflow Permissions Reference
| Permission | What it grants | Required for |
|---|---|---|
contents: read | Read repo contents | Checkout |
contents: write | Create releases, push commits | release.yml |
pages: write | Deploy to GitHub Pages | pages.yml deploy job |
id-token: write | Request OIDC token for cloud auth | Artifact attestation, cloud deployments |
packages: write | Push to GitHub Container Registry | release.yml Docker push |
pull-requests: write | Post PR comments, set labels | Code review automations |
issues: write | Create/update issues | nightly.yml failure reporting |
attestations: write | Create artifact attestations | release.yml attestation job |
security-events: write | Upload SARIF results | codeql.yml |
Secrets and Pull Requests from Forks
By default, GitHub does not pass secrets to workflow runs triggered by pull requests from forked repositories. This is intentional — a PR from a fork is code you haven’t reviewed yet, and it runs in your workflow environment.
This means if your CI workflow needs secrets (for example, to run integration tests against a real API), those tests will fail on PRs from forks.
Common patterns to handle this:
Option 1: Require approval for first-time contributors In your repository settings, you can require maintainer approval before workflows run for first-time contributors. The workflow only runs after a human has looked at the PR.
Option 2: Split tests into unit (no secrets) and integration (secrets)
Run unit tests on all PRs, including forks. Run integration tests only on pushes to main or approved branches where secrets are available.
Option 3: Use environments GitHub Environments can require a reviewer to approve before a job runs. Attach secrets to the environment, not the repository, and require approval before the environment is accessed.
The A2A project uses Option 2: the CI pipeline runs unit tests and linting on all PRs (no secrets needed), and integration tests run on main via nightly.yml.
Third-Party Actions: The Hidden Risk
Every uses: some-action/name@version line in your workflow is running code from an external repository inside your runner. That code has access to the same environment your steps do — including the secrets you’ve passed as environment variables.
Pin to Commit SHAs
Using a tag (@v4) trusts that the tag hasn’t been moved. A compromised maintainer account can move a tag to point to malicious code. A commit SHA (@abc1234) is immutable — it will always refer to exactly that commit.
# Risky — tag can be moved- uses: actions/checkout@v4
# Safe — SHA is immutable- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2The A2A project’s workflows all use pinned SHAs with a comment showing the human-readable version. See Supply Chain Security for more detail on this pattern.
Audit Third-Party Actions
Before adding a new action:
- Check the source repository — is it actively maintained? Are issues responsive?
- Read the action code —
action.ymland the referenced scripts or Docker image - Check permissions required — does the action ask for more than it needs?
- Look for prior CVEs — search the GitHub Advisory Database for the action name
Practical Checklist for Workflow Security
When writing or reviewing a workflow file, check:
- Global
permissionsis set tocontents: reador more restrictive - Each job declares only the permissions it actually needs
- All
uses:steps are pinned to commit SHAs - Secrets are passed as environment variables, not arguments
- No
echoorprintstatements output secret values -
pull-requests: writeis not granted unless the workflow posts PR comments -
workflow_dispatchinputs are validated if they’re used inruncommands (to prevent injection via inputs)
The last point is subtle: if a workflow_dispatch input is used in a shell command without quoting, an attacker with access to trigger the workflow manually could inject shell commands:
# Dangerous- run: ./deploy.sh ${{ inputs.environment }}
# Safe- run: ./deploy.sh "${{ inputs.environment }}"# Or validate the input against an allowed list first