Skip to content
DevOps devops cicd 6 min read

Secrets & Environments in GitHub Actions

Every real deployment pipeline needs passwords, API tokens, and cloud keys to do its job — and the worst thing you can do is paste them straight into your workflow file. GitHub Actions gives you encrypted secrets (values that are stored scrambled and only revealed to your job at runtime) and environments (named deploy targets you can lock behind approval rules). This page shows you how to store credentials safely, reference them with secrets.NAME, gate deployments behind human reviewers, and — best of all — get rid of long-lived cloud keys entirely using OIDC. The goal is simple: your secrets never appear in code, in logs, or in your git history.

Why never hard-code secrets

A hard-coded secret is a credential typed directly into a file. The moment you commit it, it lives in your git history forever — anyone who clones the repo, including a leaked fork, has it. Public repos are scraped by bots within minutes. Even private repos get shared, forked, and backed up in ways you do not control.

The fix is to store the secret outside your code and inject it only when the job runs. GitHub encrypts every secret at rest and decrypts it inside the runner (the virtual machine that executes your job) just long enough to use it.

Security tip: GitHub automatically masks (hides as ***) any registered secret value if it appears in your build logs. But this only works for the exact stored string. If you base64-encode or transform a secret, the masking can miss it — so never echo a secret, even by accident.

The three scopes of secrets

GitHub stores secrets at three levels. Pick the narrowest scope that works — the fewer jobs that can read a secret, the smaller your blast radius if something leaks.

ScopeWhere it livesWho can read itWhen to use
Repository secretRepo → Settings → Secrets and variables → ActionsAny workflow in that one repoA token only this project needs
Environment secretRepo → Settings → Environments → (env)Only jobs targeting that environmentProduction DB password, gated by approval
Organization secretOrg → Settings → Secrets and variables → ActionsSelected repos across the orgA shared registry token used by many repos

When to use which: start with a repository secret. Move it up to an organization secret only when several repos genuinely share the same credential (you avoid copy-pasting it everywhere). Move it down to an environment secret when the value is sensitive enough that you want approval rules in front of it (see below).

Adding a repository secret

You can do it in the web UI, or from your terminal with the GitHub CLI (gh). Install it on Ubuntu first:

sudo apt update
sudo apt install -y gh
gh auth login

Now add a secret without it ever touching a file:

gh secret set DEPLOY_TOKEN --body "ghp_xxxxRealTokenValuexxxx"

Output:

✓ Set Actions secret DEPLOY_TOKEN for your-org/your-repo

To list what exists (names only — values are never shown back to you):

gh secret list

Output:

NAME            UPDATED
DEPLOY_TOKEN    less than a minute ago
DB_PASSWORD     about 2 days ago

Referencing secrets in a workflow

Inside a workflow file you read a secret with the expression ${{ secrets.NAME }}. The safest way to use it is to pass it into a step as an environment variable under env:, rather than inlining it into a shell command (where it can leak into the command history or set -x debug output).

name: deploy
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4

      - name: Push to registry
        env:
          REGISTRY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: |
          echo "$REGISTRY_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
          docker push ghcr.io/${{ github.repository }}:latest

Notice we pipe the token into --password-stdin instead of putting it on the command line. Anything on the command line can show up in process listings and logs; standard input does not.

Gotcha: Secrets are not passed to workflows triggered by pull_request from a forked repo. This is deliberate — it stops a stranger’s pull request from stealing your production keys. Use pull_request_target only with extreme care if you need this.

Protected environments

An environment is a named deployment target — like staging or production — that you define in your repo settings. Beyond holding environment-scoped secrets, an environment lets you attach protection rules: conditions that must be met before a job using that environment is allowed to run.

The two most useful rules are:

  • Required reviewers — a person (or up to six) must click “Approve” before the deploy job starts. The job pauses and waits.
  • Wait timer — a forced delay (e.g. 10 minutes) before deploy, giving you a window to cancel.

When to use this: put required reviewers on production. Leave staging open so it deploys automatically. This gives you fast iteration on staging and a human gate before anything reaches real users.

Set it up in Settings → Environments → New environment, name it production, tick Required reviewers, and add the team. Then point a job at it:

jobs:
  release:
    runs-on: ubuntu-24.04
    environment:
      name: production
      url: https://app.example.com
    steps:
      - name: Deploy
        env:
          DB_PASSWORD: ${{ secrets.DB_PASSWORD }}   # environment-scoped secret
        run: ./deploy.sh

Because the job names environment: production, GitHub reads the DB_PASSWORD from that environment’s secrets, and the protection rules fire automatically. The workflow run shows a “Review pending” banner until a reviewer approves.

OIDC: stop storing cloud keys entirely

For cloud deploys (AWS, Azure, Google Cloud), the old way was to store a long-lived access key as a secret. OIDC (OpenID Connect — an identity protocol that lets one service prove who it is to another) removes that need. Instead of a stored key, GitHub hands your job a short-lived signed token at runtime, and your cloud trusts that token directly. Nothing long-lived is ever stored.

When to use this: always, for cloud authentication. Static keys are the single most common leaked credential. OIDC tokens expire in minutes and cannot be reused.

Here is an AWS example. First grant the job permission to request a token, then exchange it for temporary AWS credentials:

permissions:
  id-token: write   # lets the job request an OIDC token
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-24.04
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-deploy
          aws-region: eu-west-1

      - run: aws s3 sync ./dist s3://my-app-bucket --delete

On the AWS side you create an IAM role whose trust policy allows GitHub’s OIDC provider and restricts which repo and branch may assume it — so only main of your repo can get in, not any other repo on GitHub.

Best Practices

  • Never echo a secret or write it to a file the runner uploads — assume logs are public.
  • Scope tightly: prefer environment secrets for production values so approval rules apply.
  • Prefer OIDC over stored keys for all cloud auth; it eliminates the most-leaked credential type.
  • Pass secrets via env: and --password-stdin, never on the command line.
  • Rotate any secret immediately if a job that used it failed in a suspicious way, or a contributor leaves.
  • Pin actions to a full commit SHA (e.g. uses: actions/checkout@<sha>) so a compromised tag cannot steal your secrets.
  • Set least-privilege permissions: at the top of every workflow instead of relying on the default broad token.
Last updated June 15, 2026
Was this helpful?