Real workflows need credentials, API keys, deployment tokens, and configuration that varies between environments. GitHub Actions provides three concepts to manage this safely: secrets, variables, and environments.
Secrets
Secrets are encrypted at rest, masked in logs, and only available to authorised workflows. Three scopes:
| Scope | Configured at | Available to |
|---|---|---|
| Organization | Org Settings → Secrets and variables → Actions | Selected repos or all repos |
| Repository | Repo Settings → Secrets and variables → Actions | All workflows in the repo |
| Environment | Repo Settings → Environments → [name] | Only jobs targeting that environment |
If a secret with the same name exists at multiple scopes, the most-specific wins (environment > repository > organization).
Using a secret
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy to AWS
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: ./deploy.sh
Anything written to stdout that exactly matches the secret value is automatically replaced with *** in logs. This masking is not foolproof — base64-encoded or split values can leak. Treat the log as adversary-readable.
Variables
Variables look like secrets in the UI but hold non-sensitive values — region names, log levels, feature flags. They are visible in logs and accessible via the vars context:
steps:
- run: echo "Deploying to ${{ vars.AWS_REGION }}"
Use variables for config that legitimately differs between repos or environments but doesn't need encryption. Don't store secrets in vars even temporarily.
Environments
An environment is a named deployment target (staging, production, eu-prod, etc.) with its own secrets, variables, and protection rules. Created at Repo Settings → Environments.
Protection rules
- Required reviewers: Up to 6 named users/teams must approve before a job targeting this environment starts
- Wait timer: Force a delay (0-43,200 minutes / 30 days) — a safety brake before production
- Deployment branches: Restrict which branches/tags can deploy to this environment
- Custom rules: Pluggable via GitHub Apps for things like Slack-confirmation, ServiceNow change checks, etc.
Linking a job to an environment
jobs:
deploy-prod:
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
- run: ./deploy-to-prod.sh
The optional url: shows up as a clickable "View deployment" link in the GitHub UI after the job runs — useful for jumping straight to the deployed app.
The GITHUB_TOKEN
Every workflow run is given an ephemeral GITHUB_TOKEN automatically — no need to store one. It expires when the workflow ends and is scoped by the permissions: key:
permissions:
contents: read
pull-requests: write
The default permissions changed over time. For new repos, the default is read-only on most scopes — you must explicitly grant what you need. This is good security hygiene.
OIDC: The Better Way to Authenticate to Clouds
Storing long-lived AWS/Azure/GCP keys as secrets is risky — they don't rotate, they're long-lived, and a leaked workflow log can compromise your cloud. Modern best practice: OpenID Connect (OIDC) federation.
With OIDC, your workflow requests a short-lived token signed by GitHub. AWS/Azure/GCP trusts that token directly — no stored credentials. Lesson 8 covers OIDC in detail.
Secret Anti-Patterns
| Anti-pattern | Why it's dangerous |
|---|---|
Passing secrets via with: to a third-party action | The action's JS code can do anything with it |
echo $SECRET for "debugging" | Masking can miss multi-line / b64 secrets |
| Same secret across all environments | Production blast radius from a staging compromise |
| Stored long-lived cloud keys | Use OIDC instead |
| Secrets in fork PR runs | pull_request from forks does not have secrets — by design. Don't try to work around this. |
Worked Example: Staging vs Production
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh "${{ vars.STAGING_URL }}"
env:
API_TOKEN: ${{ secrets.API_TOKEN }}
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production # requires reviewer approval
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh "${{ vars.PROD_URL }}"
env:
API_TOKEN: ${{ secrets.API_TOKEN }}
The same secret name (API_TOKEN) resolves to different values in each job because the environments hold environment-scoped secrets. The production job waits for human approval before running — a pattern every team should adopt for high-blast-radius deployments.