Skip to content
7 min read·Lesson 5 of 8

Secrets, Variables, and Environments

Manage sensitive data and deployment configuration safely — repository, environment, and organization secrets; variables vs secrets; deployment environments with approvals.

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:

ScopeConfigured atAvailable to
OrganizationOrg Settings → Secrets and variables → ActionsSelected repos or all repos
RepositoryRepo Settings → Secrets and variables → ActionsAll workflows in the repo
EnvironmentRepo 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-patternWhy it's dangerous
Passing secrets via with: to a third-party actionThe action's JS code can do anything with it
echo $SECRET for "debugging"Masking can miss multi-line / b64 secrets
Same secret across all environmentsProduction blast radius from a staging compromise
Stored long-lived cloud keysUse OIDC instead
Secrets in fork PR runspull_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.

Key Takeaways

  • Secrets are encrypted, scoped to repo/environment/org, and never echoed to logs.
  • Variables (vars) hold non-sensitive configuration — readable in logs.
  • Environments group secrets and add deployment gates: required reviewers, wait timers, branch protection.
  • Use environment-scoped secrets to separate staging and production credentials.
  • Never pass secrets via `with:` to untrusted actions — they become inputs visible to the action code.

Test your knowledge

Try exam-style practice questions to reinforce what you've learned.

Practice Questions →