Secrets, environments and OIDC: deploy without leaking keys
Give pipelines exactly what they need: secrets and variables, environments with approvals, a least-privilege GITHUB_TOKEN, and OIDC so the cloud trusts your workflow with no stored key.
By the end of this lesson you'll be able to
0 of 6 completeSecrets and variables
A deploy needs things that don't belong in the repository: API keys, passwords, the region, the URL of the server. GitHub keeps them in two places:
| Secrets | Variables | |
|---|---|---|
| For | anything that grants access: keys, tokens, passwords | settings: region, log level, feature flags |
| Stored | encrypted; nobody can read the value back | plain text, visible to anyone with access |
| In logs | masked as *** | printed as written |
| In a workflow | ${{ secrets.NAME }} | ${{ vars.NAME }} |
Both exist at three levels, and the most specific wins: organisation, repository, and environment (next section).
$ gh secret set PAYMENTS_API_KEY # paste the value when asked: not in shell history $ gh secret set PAYMENTS_API_KEY --env production # a different value for one environment $ gh variable set LOG_LEVEL --body info $ gh secret list
Use them through env:, so the value reaches your script as an environment variable:
- name: Deploy env: API_KEY: ${{ secrets.PAYMENTS_API_KEY }} LOG_LEVEL: ${{ vars.LOG_LEVEL }} run: ./scripts/deploy.sh staging
Environments and approvals
An environment is a named deployment target, such as staging or production, with its own secrets,
variables and protection rules. A job that names an environment can't start until the rules pass:
Create them in Settings → Environments. The rules that matter:
| Rule | Effect |
|---|---|
| Required reviewers | the job waits until one of them approves in the run page |
| Deployment branches and tags | only main (or v* tags) may deploy here |
| Wait timer | a delay before the job starts, time to cancel |
| Prevent self-review | the person who triggered the run can't approve it |
production: needs: staging runs-on: ubuntu-latest environment: name: production url: https://shop.example.com # shown on the run and the repository's Deployments page
Environment secrets are handed to the job only after the rules pass, so an unapproved run can't even read them. That's what makes production secrets safe even in a repository many people can push to.
Concurrency: one deploy at a time
Two deploys of the same environment at once can interleave and leave a half-updated server. A
concurrency group allows one run at a time:
concurrency: group: deploy-${{ github.ref }} cancel-in-progress: false # deploys: let the running one finish, queue the next
For pull request CI, cancel-in-progress: true is right instead: a new push makes the old run pointless.
The GITHUB_TOKEN
Every run gets a GITHUB_TOKEN automatically, a token for this repository that expires when the job ends.
Its default permissions can be broad, so set them explicitly, at the top of every workflow, and widen only
the jobs that need more:
permissions: contents: read # the whole workflow: read the code, nothing else jobs: release: permissions: contents: write # this job creates a GitHub Release deploy: permissions: contents: read id-token: write # this job asks for an OIDC token
If a workflow step is ever compromised (a malicious dependency, a hijacked action), a read-only token limits what it can do. Set the repository default to read-only too: Settings → Actions → General → Workflow permissions.
OIDC: no keys to steal
The old way to deploy to AWS, Azure or GCP was to create a cloud access key and store it as a secret. That key works for anyone who gets it, from anywhere, until someone rotates it. OpenID Connect (OIDC) removes it:
- The job (with
id-token: write) asks GitHub for a signed token that says which repository, branch, environment and workflow it is. - It sends the token to the cloud. The cloud checks the signature and your trust policy.
- If they match, the cloud returns temporary credentials, valid for about an hour.
On AWS, the trust policy of the deploy role decides exactly who may use it:
{ "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", "token.actions.githubusercontent.com:sub": "repo:elonerajeev/shop-api:environment:production" } } }] }
production: environment: production permissions: contents: read id-token: write steps: - uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam::123456789012:role/shop-api-deploy aws-region: ap-south-1 - run: aws sts get-caller-identity # proves who the job is now
The sub condition is the security: only jobs in this repository using the production environment
(which itself requires approval and the main branch) can assume the role. A pull request, a fork or another
repository gets nothing. Always pin sub; a trust policy that checks only aud lets every repository on
GitHub use your role.
Gate production behind an approval
Add staging and production environments to shop-api. Staging deploys on every push to main; production needs staging to succeed, may only deploy from main, and waits for your approval. Store a secret per environment, keep the token read-only, and make sure only one deploy runs at a time.
A run where production waits for approval, then deploysReveal one safe solution
$ mkdir -p scripts && cat > scripts/deploy.sh <<'EOF' #!/usr/bin/env bash set -euo pipefail target=${1:?usage: deploy.sh <staging|production>} [[ -n ${API_KEY:-} ]] || { echo "API_KEY is not set for $target" >&2; exit 1; } echo "Deploying shop-api $GITHUB_SHA to $target (log level ${LOG_LEVEL:-info})" echo "API key is set (${#API_KEY} characters)" # prove it arrived without printing any of it EOF $ chmod +x scripts/deploy.sh $ gh api -X PUT "repos/{owner}/{repo}/environments/staging" > /dev/null $ gh api -X PUT "repos/{owner}/{repo}/environments/production" > /dev/null $ gh secret set PAYMENTS_API_KEY --env staging --body "staging-demo-key" $ gh secret set PAYMENTS_API_KEY --env production --body "production-demo-key" $ gh variable set LOG_LEVEL --body info $ nano .github/workflows/deploy.yml # this lesson's workflow, without the AWS steps $ actionlint && git add . && git commit -m "ci: deploy to staging, then production with approval" && git push
mkdir -p scripts && cat > scripts/deploy.sh <<'EOF'mkdirmake a directory-pcreate missing parents, no error if it exists&&run the next command only if this one workedcatprint a file>write output to a file, replacing it
chmod +x scripts/deploy.shchmodchange permissions
actionlint && git add . && git commit -m "ci: deploy to staging, then production with approval" && git pushgitversion controladdstage changes.this foldercommitsave staged changespushupload commits
Then in Settings → Environments → production, add yourself under Required reviewers and limit
Deployment branches to main. Push again: staging deploys, and production shows Waiting for review
until you click Review deployments → Approve. The log line API key is set (19 characters) proves the
secret arrived without printing any part of it: even a few characters of a key help an attacker. Add the AWS steps once you've created the role from the AWS course.
Try it yourself
0 of 6 steps doneRun each task in your own account or terminal, then tick it off.
Quick check
What is the difference between a secret and a variable in GitHub Actions?
Official reading for this lesson
Or get every quiz answer right and it completes itself.