Intermediate Lesson 6 of 14

Secrets & Variables

Securely manage credentials, API keys, and configuration across repository, environment, and organization scopes.

πŸ§’ Simple Explanation (ELI5)

Imagine you're staying at a hotel.

GitHub Actions gives you three levels of safes (organization β†’ repository β†’ environment) so the right credentials are available to the right workflows at the right time β€” and nothing more.

πŸ” Repository Secrets

Repository secrets are the most common way to store sensitive values. They're scoped to a single repository and available to all workflows in that repo.

Creating a Repository Secret

  1. Go to your repository β†’ Settings β†’ Secrets and variables β†’ Actions
  2. Click New repository secret
  3. Enter a name (e.g., ACR_PASSWORD) and the secret value
  4. Click Add secret

Using Secrets in Workflows

yaml
steps:
  - name: Login to ACR
    uses: azure/docker-login@v1
    with:
      login-server: ${{ secrets.ACR_LOGIN_SERVER }}
      username: ${{ secrets.ACR_USERNAME }}
      password: ${{ secrets.ACR_PASSWORD }}

  - name: Use secret in a run step
    env:
      DB_CONNECTION: ${{ secrets.DATABASE_URL }}
    run: echo "Connecting to database..."
πŸ’‘
Key Behaviors
  • Secrets are automatically masked in workflow logs β€” if the value appears in output, GitHub replaces it with ***
  • You cannot read back a secret after creation β€” you can only update or delete it
  • Secrets are encrypted at rest using libsodium sealed boxes
  • Access secrets via ${{ secrets.SECRET_NAME }} β€” names are case-insensitive but convention is UPPER_SNAKE_CASE

🌍 Environment Secrets

Environment secrets are scoped to a specific deployment environment (e.g., staging, production). They override repository secrets with the same name, and they're only available when a job explicitly references that environment.

Setting Up Environments

  1. Go to Settings β†’ Environments β†’ New environment
  2. Name it (e.g., production)
  3. Add protection rules: required reviewers, wait timers, branch restrictions
  4. Add environment-specific secrets and variables

Using Environment Secrets

yaml
jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment: staging          # Uses staging secrets
    steps:
      - name: Deploy
        env:
          API_KEY: ${{ secrets.API_KEY }}    # staging API key
        run: ./deploy.sh

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment: production       # Uses production secrets
    steps:
      - name: Deploy
        env:
          API_KEY: ${{ secrets.API_KEY }}    # production API key (different value!)
        run: ./deploy.sh
⚠️
Environment Secrets Override Repo Secrets

If both the repo and the environment have a secret named API_KEY, the environment secret wins when the job references that environment. This is by design β€” it lets you have test credentials at the repo level and real credentials scoped to production.

🏒 Organization Secrets

Organization secrets are shared across multiple repositories. They're ideal for credentials that many repos need β€” like a shared container registry password or a monitoring API key.

Visibility Policies

Creating Organization Secrets

Go to your organization β†’ Settings β†’ Secrets and variables β†’ Actions β†’ New organization secret. Choose the visibility policy and save.

πŸ’‘
Cost-Effective Credential Management

Instead of duplicating the same secret across 50 repos, create one org secret with "selected repositories" access. When credentials rotate, you update a single secret instead of 50. This dramatically reduces the risk of stale or inconsistent credentials.

πŸ“‹ Variables (Not Secret)

Variables store non-sensitive configuration values. Unlike secrets, they're visible in logs and can be read back from the UI. Use them for API base URLs, feature flags, version numbers, and environment names.

Creating Variables

Go to Settings β†’ Secrets and variables β†’ Actions β†’ Variables tab β†’ New repository variable. Variables also support repository, environment, and organization scopes β€” the same hierarchy as secrets.

Using Variables in Workflows

yaml
env:
  APP_ENV: ${{ vars.ENVIRONMENT_NAME }}
  API_URL: ${{ vars.API_BASE_URL }}
  FEATURE_NEW_UI: ${{ vars.FEATURE_NEW_UI }}

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Show config
        run: |
          echo "Deploying to: ${{ vars.ENVIRONMENT_NAME }}"
          echo "API endpoint: ${{ vars.API_BASE_URL }}"
          echo "New UI enabled: ${{ vars.FEATURE_NEW_UI }}"
⚠️
Secrets vs Variables β€” When to Use Which
Use SecretsUse Variables
Passwords, tokens, API keysAPI base URLs, feature flags
Database connection stringsEnvironment names (staging, prod)
Private keys, certificatesVersion numbers, build configs
Registry credentialsNon-sensitive IDs (tenant ID, client ID)

πŸͺͺ OIDC (OpenID Connect) β€” Passwordless Cloud Auth

OIDC eliminates the need for stored cloud credentials entirely. Instead of long-lived secrets, GitHub Actions requests short-lived tokens directly from your cloud provider. No secrets to rotate, no credentials to leak.

How OIDC Works

text
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     1. Request OIDC token     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                     β”‚ ─────────────────────────────▢ β”‚                     β”‚
β”‚   GitHub Actions    β”‚                                β”‚   GitHub OIDC       β”‚
β”‚   Workflow          β”‚ ◀───────────────────────────── β”‚   Provider          β”‚
β”‚                     β”‚     2. Return signed JWT       β”‚                     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚
         β”‚  3. Present JWT to cloud provider
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                     β”‚     4. Validate JWT issuer     β”‚                     β”‚
β”‚   Cloud Provider    β”‚ ─────────────────────────────▢ β”‚   Trust Policy      β”‚
β”‚   (Azure/AWS/GCP)   β”‚                                β”‚   (Federated Cred)  β”‚
β”‚                     β”‚ ◀───────────────────────────── β”‚                     β”‚
β”‚                     β”‚     5. Issue short-lived token  β”‚                     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚
         β”‚  6. Use short-lived token to access resources
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Cloud Resources   β”‚
β”‚   (ACR, AKS, S3...) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Azure OIDC Example

Prerequisites: Create a federated credential on your Azure AD app registration that trusts your GitHub repo.

yaml
name: Deploy with OIDC
on:
  push:
    branches: [main]

permissions:
  id-token: write        # Required for OIDC
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Azure Login (OIDC β€” no secrets needed!)
        uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

      - name: Deploy to AKS
        run: |
          az aks get-credentials --resource-group myRG --name myAKS
          kubectl apply -f k8s/
πŸ’‘
Notice: vars, Not secrets

With OIDC, the client ID, tenant ID, and subscription ID are stored as variables (vars.), not secrets. They're not sensitive on their own β€” the security comes from the trust relationship configured in Azure AD. No password or client secret is stored anywhere.

AWS OIDC Example

Prerequisites: Create an IAM OIDC identity provider for token.actions.githubusercontent.com and a role with a trust policy scoped to your repo.

yaml
      - name: Configure AWS Credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
          aws-region: us-east-1

      - name: Push to ECR
        run: |
          aws ecr get-login-password | docker login --username AWS --password-stdin 123456789012.dkr.ecr.us-east-1.amazonaws.com
          docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/myapp:${{ github.sha }}

πŸ’» Code Examples β€” Secrets vs OIDC

Here's a side-by-side comparison of the traditional (stored credentials) vs modern (OIDC) approach:

Traditional β€” Stored Credentials

yaml
# ❌ Traditional: secrets that need rotation
- name: Login to ACR
  uses: azure/docker-login@v1
  with:
    login-server: ${{ secrets.ACR_LOGIN_SERVER }}
    username: ${{ secrets.ACR_USERNAME }}
    password: ${{ secrets.ACR_PASSWORD }}

Modern β€” OIDC (No Secrets Needed)

yaml
# βœ… Modern: OIDC β€” no stored credentials
- name: Azure Login (OIDC)
  uses: azure/login@v2
  with:
    client-id: ${{ vars.AZURE_CLIENT_ID }}
    tenant-id: ${{ vars.AZURE_TENANT_ID }}
    subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

- name: Login to ACR via OIDC
  run: az acr login --name myregistry

πŸ“Š Secret & Variable Hierarchy

ScopePrecedenceVisibilityBest For
EnvironmentπŸ₯‡ Highest (wins)Jobs referencing that environment onlyPer-stage credentials (staging vs production keys)
RepositoryπŸ₯ˆ MediumAll workflows in the repoRepo-specific credentials (Codecov token, ACR password)
OrganizationπŸ₯‰ Lowest (overridden)All/private/selected repos in the orgShared credentials (org-wide registry, monitoring keys)

When the same secret name exists at multiple levels, the most specific scope wins: environment overrides repository, which overrides organization.

πŸ“ Secret Resolution Flow

text
  ${{ secrets.API_KEY }} referenced in a workflow step
                    β”‚
                    β–Ό
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  Does the job have `environment:`?  β”‚
  β”‚  e.g., environment: production      β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
             β”‚ YES          β”‚ NO
             β–Ό              β”‚
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β”‚
  β”‚  Environment has  β”‚      β”‚
  β”‚  API_KEY secret?  β”‚      β”‚
  β””β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”˜      β”‚
     β”‚ YES       β”‚ NO       β”‚
     β–Ό           β–Ό          β–Ό
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚ USE IT β”‚  β”‚  Repository has      β”‚
  β”‚ (done) β”‚  β”‚  API_KEY secret?     β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”˜
                 β”‚ YES          β”‚ NO
                 β–Ό              β–Ό
           β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
           β”‚ USE IT β”‚  β”‚  Organization has    β”‚
           β”‚ (done) β”‚  β”‚  API_KEY secret?     β”‚
           β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”˜
                          β”‚ YES          β”‚ NO
                          β–Ό              β–Ό
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚ USE IT β”‚    β”‚ Empty stringβ”‚
                    β”‚ (done) β”‚    β”‚ (not found) β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ› οΈ Hands-on Exercises

πŸ‹οΈ
Exercise 1 β€” Create and Use a Repository Secret

Create a repository secret named MY_SECRET_MESSAGE with any value. Write a workflow that uses it in an env: block and runs echo "The secret is: $MY_SECRET_MESSAGE". Observe that the value is masked with *** in the logs.

πŸ‹οΈ
Exercise 2 β€” Try to Print a Secret Directly

In a workflow step, try echo "${{ secrets.MY_SECRET_MESSAGE }}". Check the logs β€” GitHub masks the value. Now try echo "${{ secrets.MY_SECRET_MESSAGE }}" | base64. Notice the value is still masked because GitHub detects the original value in the base64 output. Understand why secrets must never be piped into URLs or concatenated with other strings.

πŸ‹οΈ
Exercise 3 β€” Environment Secrets Override

Create a repo secret API_KEY with value repo-key. Create a staging environment with API_KEY set to staging-key. Write two jobs: one without an environment: declaration and one with environment: staging. Both echo the API_KEY length. Verify the staging job gets a different (masked) value.

πŸ‹οΈ
Exercise 4 β€” Configure OIDC for Azure

Set up a federated credential in Azure AD for your GitHub repo. Create variables AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_SUBSCRIPTION_ID. Write a workflow with permissions: id-token: write that uses azure/login@v2 with OIDC. Run az account show to verify authentication works β€” no secrets stored anywhere.

πŸ‹οΈ
Exercise 5 β€” Variables for Feature Flags

Create repository variables: FEATURE_NEW_UI=true, API_BASE_URL=https://api.example.com. Write a workflow that uses ${{ vars.FEATURE_NEW_UI }} in a conditional: if true, run the new UI build; if false, run the legacy build. Change the variable in the UI and re-run to see the different path execute.

πŸ› Common Issues & Debugging

Secret Is Empty

text
Error: Input required and not supplied: password

Cause: Wrong secret name, wrong case (though names are case-insensitive, typos happen), or the secret doesn't exist at the scope the job is using.

Fix: Double-check the name in Settings β†’ Secrets and variables. If using environment secrets, make sure the job has environment: set. For fork PRs, note that secrets are not available in workflows triggered by pull requests from forks β€” this is a security feature to prevent exfiltration.

Secret Value Visible in Logs

Cause: The secret was used in a URL (https://user:${{ secrets.TOKEN }}@github.com), base64 encoded, or split across multiple echo statements where individual characters don't trigger masking.

Fix: Never embed secrets in URLs directly. Use git config or environment variables instead. For base64-encoded values, GitHub masks the original but may not catch the encoded form if it's generated at runtime. Always pass secrets through env: blocks rather than inline in run: commands.

OIDC Token Request Fails

text
Error: Unable to get ACTIONS_ID_TOKEN_REQUEST_URL env variable

Cause: Missing permissions: id-token: write on the job or workflow.

Fix: Add permissions: { id-token: write, contents: read }. When you set any permission explicitly, all other permissions default to none β€” so you must also add contents: read for checkout to work.

Environment Secret Not Overriding Repo Secret

Cause: The job doesn't declare environment: <name>, so it uses the repo-level secret.

Fix: Add environment: production (or the appropriate name) to the job definition. Environment secrets only take effect when the job explicitly targets that environment.

🎯 Interview Questions

Beginner (B1–B5)

B1: What is a GitHub Actions secret and how is it different from a variable?

A secret stores sensitive values (passwords, tokens) that are encrypted at rest and masked in logs. A variable stores non-sensitive configuration (URLs, flags) that is visible in logs. Secrets use ${{ secrets.NAME }}, variables use ${{ vars.NAME }}.

B2: Can you read back a secret's value after creating it in GitHub?

No. Once a secret is saved, you can only update or delete it β€” you cannot view the stored value. This is a security feature. If you've lost the value, you must set a new one.

B3: How do you reference a secret inside a workflow YAML file?

Use the expression syntax: ${{ secrets.SECRET_NAME }}. Typically you pass it via the with: block for actions or via the env: block for shell commands.

B4: What happens if you echo a secret in a workflow step?

GitHub automatically masks the value β€” it replaces the actual content with *** in the log output. This prevents accidental exposure in workflow logs.

B5: What are the three scopes for GitHub Actions secrets?

Organization secrets (shared across repos), repository secrets (scoped to one repo), and environment secrets (scoped to a specific deployment environment like staging or production).

Intermediate (I1–I5)

I1: Explain the precedence order when secrets exist at multiple scopes.

Environment secrets take highest precedence, followed by repository secrets, then organization secrets. If a secret named API_KEY exists at all three levels and the job targets an environment, the environment value wins. This allows global defaults with per-environment overrides.

I2: Why are secrets not available in workflows triggered by fork pull requests?

To prevent secret exfiltration. A malicious contributor could fork your repo, modify the workflow to echo secrets, and submit a PR. If secrets were available, they'd be exposed in the fork's workflow logs. GitHub blocks this by not passing secrets to fork-triggered workflows.

I3: What is the advantage of environment secrets over repository secrets?

Environment secrets provide scoped access β€” production credentials are only available to jobs targeting the production environment. Combined with environment protection rules (required reviewers, branch restrictions), they enforce separation of duties. A developer can deploy to staging without ever seeing production credentials.

I4: How would you rotate a secret used by 30 repositories in an organization?

Use an organization secret with a "selected repositories" policy. When it's time to rotate, update the single org secret. All 30 repos automatically use the new value on their next workflow run. Without org secrets, you'd update 30 individual repo secrets β€” error-prone and slow.

I5: When should you use variables (vars) instead of hardcoding values in YAML?

When the value differs across environments or may change without code changes. API base URLs, feature flags, version numbers, and resource names are good candidates. Variables let you change config from the UI without modifying workflow files, and they keep configuration centralized and visible.

Senior (S1–S5)

S1: Explain OIDC for GitHub Actions. Why is it preferred over stored credentials?

OIDC uses federated identity β€” GitHub issues a signed JWT, the cloud provider validates it against a trust policy, and issues a short-lived access token. Benefits: no long-lived credentials to store or rotate, tokens expire in minutes, blast radius of a compromise is minimal, and there's an auditable trust chain. It eliminates entire categories of secret management overhead.

S2: A workflow accidentally exposes a secret in a URL logged to the console. What's your incident response?

Immediately: (1) Rotate the compromised credential. (2) Delete the workflow run logs from GitHub. (3) Audit where the credential was used β€” check for unauthorized access. (4) Fix the workflow to never embed secrets in URLs. (5) Add a PR review gate for workflow file changes. Long-term: migrate to OIDC to eliminate stored credentials where possible.

S3: How would you design a secret management strategy for a 200-repo organization with staging and production environments?

Tier 1: Org secrets for truly shared credentials (monitoring, shared registry) with "selected repos" policies. Tier 2: Repo secrets for repo-specific values. Tier 3: Environment secrets for staging/prod differentiation with required reviewers on production. Migrate cloud auth to OIDC wherever possible. Use Terraform or a script to manage environment configurations at scale. Audit secret access via GitHub's audit log API.

S4: What are the security implications of permissions: id-token: write and how do you scope it safely?

The id-token: write permission allows a workflow to request an OIDC token. It should be set at the job level, not the workflow level, so only jobs that need cloud auth can request tokens. The cloud side must also validate claims β€” restrict the trust policy to specific repos, branches, and environments. A misconfigured trust policy (e.g., trusting all repos in an org) could allow any repo to assume the cloud role.

S5: Your team's production deploy failed because a secret wasn't set in the new production environment. How do you prevent this class of error?

Add a preflight validation job that checks required secrets exist (test that ${{ secrets.X }} is non-empty and fail with a descriptive message). Document required secrets per environment in a SECRETS.md file. Use IaC (e.g., Terraform's github_actions_environment_secret resource) to manage environment configuration declaratively β€” drift detection catches missing secrets before deploy time.

🌍 Real-World Scenario: Migrating from Stored Credentials to OIDC

A SaaS company running on Azure has 40 repositories deploying to AKS. Each repo stores three secrets β€” AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, and AZURE_TENANT_ID β€” for a total of 120 stored credentials. Every 90 days, the security team rotates all client secrets manually. This process takes two days and has caused three production outages from missed rotations in the past year.

The Migration

  1. Created federated credentials in Azure AD for each repo, scoped to main branch and production environment
  2. Converted secrets to variables β€” client ID and tenant ID moved from secrets. to vars. (they're not sensitive with OIDC)
  3. Removed client secrets entirely β€” no more AZURE_CLIENT_SECRET in any repo
  4. Added permissions: id-token: write to deployment jobs
  5. Switched from azure/login@v1 to azure/login@v2 with OIDC parameters

Results

πŸ“ Summary

← Back to GitHub Actions Course