Overview

Actions secrets are encrypted values injected into a workflow at run time. Prefer OIDC to long-lived cloud credentials, choose the narrowest secret scope, never trust the log mask, and rotate on a schedule and after any exposure. Workflow structure and GITHUB_TOKEN scope are in github-actions.

Prefer OIDC to long-lived cloud credentials

A static key is a liability from the day it is created. With OpenID Connect, the job requests a short-lived token from the cloud provider, which validates the claims in GitHub’s OIDC token against a trust policy. AWS, Azure, Google Cloud, and HashiCorp Vault all support it.

permissions:
  id-token: write
  contents: read
steps:
  - uses: aws-actions/configure-aws-credentials@v6
    with:
      role-to-assume: arn:aws:iam::123456789012:role/MyRole
      aws-region: us-east-1

Write the trust policy narrowly: match the repository and, for production roles, the environment or branch in the sub claim. Anything broader lets any workflow in the owner assume the role.

Check the subject format your repository emits. GitHub is moving sub to an immutable form that carries owner and repository IDs, for example repo:my-org@123456/my-repo@456789:ref:refs/heads/main. Repositories created after July 15, 2026, and repositories renamed or transferred after that date, use it by default. Existing repositories keep the old form until you opt in through the repository or organization OIDC settings. A trust policy written for the old name-based form will not match the new one, so update the cloud-side condition when you switch. Source: GitHub changelog.

Choose the narrowest secret scope

  • Repository: every workflow in the repository can read it. Use for tokens all workflows need.
  • Environment: injected only into jobs that target the named environment. Use for deploy credentials, and add required reviewers (up to 6) so a human approves access to production.
  • Organization: shared across repositories by policy. Use for tokens you would otherwise duplicate.

Environments for private repositories need a paid plan (Pro, Team, or Enterprise); on the Free plan they work for public repositories only. Secrets are not passed to workflows triggered from forks, apart from GITHUB_TOKEN, and are not passed to reusable workflows unless the caller passes them. A secret is limited to 48 KB.

Never print a secret, and never trust the mask

GitHub redacts known secret values in logs with ***, but a transformed value (Base64, URL-encoded, split across a string operation) is not redacted. Do not wrap secrets in JSON, YAML, or XML, which defeats redaction, and register any derived value with ::add-mask::. Pass secrets through env, not through ${{ }} in the script text.

# Wrong: the expression is expanded into the script
- run: curl -H "Authorization: ${{ secrets.API_TOKEN }}" "$URL"
 
# Right: the shell reads it as a variable
- run: curl -H "Authorization: $API_TOKEN" "$URL"
  env:
    API_TOKEN: ${{ secrets.API_TOKEN }}
    URL: ${{ inputs.url }}

Turn on secret scanning and push protection

Secret scanning runs automatically and free on public repositories. For private and internal repositories owned by an organization, it requires GitHub Secret Protection on a Team or Enterprise Cloud plan. Enable push protection so a push containing a detected secret is blocked before it lands; a contributor can still bypass it by stating a reason. Add custom patterns for credentials the built-in detectors miss. Source: About secret scanning.

Rotate on a schedule and after any exposure

Rotate on a cadence proportional to the blast radius; deploy credentials need it more than read-only tokens. Treat any secret that appeared in a log, a diff, or a PR description as compromised and rotate it before investigating. After rotating, verify the new value in a non-production environment before revoking the old one. Fewer static secrets means less to rotate, which is the case for OIDC.