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 neverechoa 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.
| Scope | Where it lives | Who can read it | When to use |
|---|---|---|---|
| Repository secret | Repo → Settings → Secrets and variables → Actions | Any workflow in that one repo | A token only this project needs |
| Environment secret | Repo → Settings → Environments → (env) | Only jobs targeting that environment | Production DB password, gated by approval |
| Organization secret | Org → Settings → Secrets and variables → Actions | Selected repos across the org | A 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_requestfrom a forked repo. This is deliberate — it stops a stranger’s pull request from stealing your production keys. Usepull_request_targetonly 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
echoa 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.