M
MeshWorld.
GitHub Actions Security DevOps CI/CD Secrets GitHub Best Practices 7 min read

GitHub Actions Secrets: Best Practices to Stop Leaking Credentials

Vishnu
By Vishnu

:::note[TL;DR]

  • Store secrets in the narrowest scope: repository secret → environment secret → organization secret
  • Pass secrets as environment variables, never as CLI arguments — arguments appear in process listings and logs
  • Never echo, print, or log a secret — GitHub’s masking can be bypassed with encoding tricks like base64
  • Pin third-party Actions to a commit SHA, not a tag — tags can be silently moved to point at malicious code
  • Use OIDC for cloud providers (AWS, GCP, Azure) instead of long-lived access keys stored as secrets :::

GitHub Actions secrets are encrypted environment variables stored by GitHub and injected into workflow runs. They never appear in logs — GitHub automatically masks known secret values. But there are enough ways to accidentally expose them that it’s worth covering the right way to handle them.

Credential leaks through CI/CD are one of the most common causes of cloud account compromises. Here’s how to not be a statistic.

The right places to store secrets

GitHub gives you three scopes:

Repository secrets — only accessible to workflows in one repo: Settings → Secrets and variables → Actions → New repository secret

Environment secrets — tied to a deployment environment (staging, production), with optional protection rules: Settings → Environments → [env name] → Add secret

Organization secrets — shared across multiple repos in an org: Org Settings → Secrets and variables → Actions

Use the narrowest scope that works. If only one repo needs a secret, use a repository secret. If it’s a deploy key for production, use an environment secret with a required reviewer.

How to use secrets in workflows

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy
        env:
          API_KEY: ${{ secrets.API_KEY }}
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
        run: ./scripts/deploy.sh

Pass secrets as environment variables, not as command-line arguments. Arguments appear in process listings and shell history.

Never do this:

# BAD — appears in logs and process listings
run: curl -H "Authorization: Bearer ${{ secrets.TOKEN }}" https://api.example.com

Do this:

# GOOD — set as env var, used by the script
env:
  AUTH_TOKEN: ${{ secrets.TOKEN }}
run: |
  curl -H "Authorization: Bearer $AUTH_TOKEN" https://api.example.com

Common mistakes

Echoing secrets

# BAD — this leaks the secret even though GitHub masks it in simple cases
run: echo "Token is ${{ secrets.TOKEN }}"

# GitHub masks literal values, but encoding tricks can bypass it
run: echo "${{ secrets.TOKEN }}" | base64  # leaks the base64-encoded value

Never echo, print, or log secrets — even “just for debugging.”

Passing secrets to untrusted actions

Third-party Actions in your workflow have access to environment variables you set. Only pass secrets to actions you trust — either official GitHub actions (actions/) or ones you’ve audited.

# Be careful with third-party actions that receive secrets
- uses: some-random-publisher/deploy-action@v1
  with:
    api_key: ${{ secrets.API_KEY }}  # this action can exfiltrate this

Pin third-party actions to a specific commit SHA instead of a tag:

# Instead of:
uses: some-action@v1

# Use:
uses: some-action@a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2  # full SHA

:::warning Tags can be silently moved to point at a different (malicious) commit. If you pin uses: some-action@v2 and the publisher’s account is compromised, the tag can be updated to run attacker code in your workflow — with access to all your secrets. Pinning to a full commit SHA eliminates this attack vector. :::

Tags can be moved. SHAs can’t.

Pull request workflows from forks

By default, pull_request events from forks don’t have access to secrets (this is a good default). But pull_request_target does — and it runs in the context of the base repo.

# DANGEROUS — runs fork code with access to secrets
on:
  pull_request_target:
    types: [opened, synchronize]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # checking out untrusted code

Never check out untrusted fork code in a pull_request_target workflow that has access to secrets.

The scenario: A maintainer sets up pull_request_target so forks can trigger CI with secrets (for deploy previews). An attacker opens a PR with a modified workflow file that exfiltrates ${{ secrets.DEPLOY_KEY }}. The maintainer merges without reading the workflow change carefully. The production SSH key is now compromised. This is a real attack pattern.

Use environments with protection rules

For production deployments, use Environments to add a required reviewer:

jobs:
  deploy-production:
    environment: production    # triggers environment protection rules
    runs-on: ubuntu-latest
    steps:
      - name: Deploy
        env:
          PROD_KEY: ${{ secrets.PROD_KEY }}
        run: ./deploy-prod.sh

In Settings → Environments → production, add required reviewers. No deploy happens without human approval.

Rotate secrets regularly

Set a calendar reminder to rotate:

  • Deploy keys: every 90 days
  • API keys with write access: every 60 days
  • Cloud provider access keys: every 30-60 days

When someone leaves the team, rotate everything they had access to immediately.

Use OIDC instead of long-lived credentials

For cloud providers (AWS, GCP, Azure), use OpenID Connect to get short-lived credentials instead of storing long-lived keys as secrets.

AWS example:

permissions:
  id-token: write
  contents: read

steps:
  - name: Configure AWS credentials
    uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::123456789012:role/GitHubActions
      aws-region: ap-south-1

No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY stored anywhere. The token lasts for the duration of the job and expires automatically.

:::tip OIDC is strictly safer than long-lived access keys: the credentials expire when the job ends, there’s nothing to rotate on a schedule, and a leaked token is already useless by the time anyone tries to use it. All major cloud providers support OIDC with GitHub Actions — set it up once per environment. :::

Audit who has access

Check periodically:

  • Which repos have access to organization secrets
  • Which environments have required reviewers turned off
  • Which team members have admin access to secrets settings

GitHub’s audit log (Org Settings → Audit log) shows secret access and changes.

Detect leaked secrets

Enable GitHub’s Secret Scanning (free for public repos, included in GitHub Advanced Security for private):

Settings → Security → Secret scanning → Enable

It scans your repo history for known secret patterns (API keys, tokens, private keys) and alerts you. If it finds something, rotate immediately — assume it was seen.

Related: How to Set Up CI/CD with GitHub Actions


Summary

  • Store secrets in the narrowest scope that works: repository → environment → organization
  • Pass secrets as environment variables, never as command-line arguments — arguments appear in process listings
  • Never echo, print, or log a secret value — even GitHub’s masking can be bypassed with encoding tricks
  • Pin third-party Actions to a commit SHA instead of a tag to prevent tag-mover supply chain attacks
  • Use OIDC instead of long-lived cloud credentials — short-lived tokens that expire automatically are strictly safer

Frequently Asked Questions

What happens if I accidentally commit a secret to a public repo?

Rotate it immediately — assume it was seen and potentially used. GitHub’s secret scanning may alert you, but bots scrape GitHub in near real time. After rotating, use git filter-repo to rewrite history and remove the secret from all commits. Force-push the cleaned history.

Can GitHub see my repository secrets?

GitHub encrypts secrets with libsodium sealed boxes before storage. GitHub employees cannot see plaintext secret values. Secrets are decrypted only within the Actions runner environment for your workflow. That said, a malicious third-party Action you grant secrets to can exfiltrate them.

How do I share a secret across multiple repos without duplication?

Use an organization secret and grant it to specific repositories. Settings → Org Settings → Secrets and variables → Actions → New organization secret → select which repositories can access it.