Skip to content

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

  1. Go to Settings → Secrets and variables → Actions
  2. Click New repository secret
  3. Enter a name (convention: SCREAMING_SNAKE_CASE) and value
  4. 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 default
permissions:
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 attestation

Why 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

PermissionWhat it grantsRequired for
contents: readRead repo contentsCheckout
contents: writeCreate releases, push commitsrelease.yml
pages: writeDeploy to GitHub Pagespages.yml deploy job
id-token: writeRequest OIDC token for cloud authArtifact attestation, cloud deployments
packages: writePush to GitHub Container Registryrelease.yml Docker push
pull-requests: writePost PR comments, set labelsCode review automations
issues: writeCreate/update issuesnightly.yml failure reporting
attestations: writeCreate artifact attestationsrelease.yml attestation job
security-events: writeUpload SARIF resultscodeql.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.2

The 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:

  1. Check the source repository — is it actively maintained? Are issues responsive?
  2. Read the action codeaction.yml and the referenced scripts or Docker image
  3. Check permissions required — does the action ask for more than it needs?
  4. 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 permissions is set to contents: read or 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 echo or print statements output secret values
  • pull-requests: write is not granted unless the workflow posts PR comments
  • workflow_dispatch inputs are validated if they’re used in run commands (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