Learnixo

GitHub Actions · Lesson 2 of 5

Secrets and Environment Variables in GitHub Actions

Types of GitHub Secrets

Repository secrets:
  → Available to all workflows in the repository
  → Set in: Settings → Secrets and variables → Actions → New repository secret
  → Use for: general CI secrets (Codecov token, NuGet API key)

Environment secrets:
  → Scoped to a specific environment (staging, production)
  → Require environment protection rules (manual approval before deploy)
  → Set in: Settings → Environments → {env-name} → Secrets
  → Use for: production database passwords, production API keys

Organization secrets:
  → Shared across multiple repositories in an organisation
  → Use for: company-wide secrets (Docker registry credentials, Slack webhooks)

OIDC (keyless):
  → No stored secret — GitHub's OIDC provider issues short-lived tokens
  → Azure validates the token and grants permissions based on the workflow
  → Zero credentials to rotate, zero credentials to leak
  → Use for: authenticating to Azure from GitHub Actions (preferred)

Using Secrets in Workflows

YAML
# Access secrets via ${{ secrets.SECRET_NAME }}
# They are masked in logs  GitHub replaces the value with ***

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to Azure
        env:
          # Pass secret as environment variable to the step
          AZURE_PUBLISH_PROFILE: ${{ secrets.AZURE_PUBLISH_PROFILE }}
        run: |
          echo "Deploying..."
          # AZURE_PUBLISH_PROFILE available as env var in this step

      # Or pass directly to an action:
      - name: Azure login
        uses: azure/login@v2
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}  # JSON with SP credentials

OIDC — Keyless Authentication to Azure (Recommended)

YAML
# No stored Azure credentials  GitHub issues OIDC token, Azure validates it
# Prerequisites: configure Federated Identity Credential in Azure AD App Registration

name: Deploy

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

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production  # environment-scoped protection rules apply

    steps:
      - uses: actions/checkout@v4

      - name: Azure login (OIDC  no stored secret)
        uses: azure/login@v2
        with:
          client-id:       ${{ vars.AZURE_CLIENT_ID }}      # not a secret  app registration ID
          tenant-id:       ${{ vars.AZURE_TENANT_ID }}      # not a secret
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} # not a secret

      # From here, Azure CLI and SDK use the OIDC-issued token
      - name: Deploy to App Service
        run: |
          az webapp deployment source config-zip \
            --resource-group clinical-rg \
            --name clinical-platform \
            --src ./publish.zip
Bash
# Setup: create Azure AD App Registration + Federated Identity Credential
az ad app create --display-name "GitHub Actions Clinical Platform"

az ad app federated-credential create \
  --id $APP_ID \
  --parameters '{
    "name": "github-actions-main",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:your-org/clinical-platform:ref:refs/heads/main",
    "audiences": ["api://AzureADTokenExchange"]
  }'
# subject restricts to: only the main branch of this specific repo can get this token

Environment Protection Rules

YAML
# Require manual approval before deploying to production
# Settings  Environments  production  Required reviewers: [your team lead]

jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment: staging  # no protection rules  auto-deploys

  deploy-production:
    runs-on: ubuntu-latest
    environment: production  # requires manual approval
    needs: [build-and-test, deploy-staging]

    steps:
      # This step only runs after a reviewer approves the deployment
      - name: Deploy to production
        uses: azure/webapps-deploy@v3
        with:
          app-name: 'clinical-platform-prod'
          # AZURE_PUBLISH_PROFILE is scoped to the 'production' environment
          publish-profile: ${{ secrets.AZURE_PUBLISH_PROFILE }}

Preventing Secret Exposure

YAML
# Common mistake: echoing secrets in logs
- name: Debug  # BAD  exposes secrets
  run: echo "DB password is ${{ secrets.DB_PASSWORD }}"
  # Even though GitHub masks known secrets, avoid this pattern

# Safe patterns:
- name: Check secret is set
  run: |
    if [ -z "$DB_PASSWORD" ]; then
      echo "DB_PASSWORD secret is not set"
      exit 1
    fi
    echo "DB_PASSWORD is set (length: ${#DB_PASSWORD})"
  env:
    DB_PASSWORD: ${{ secrets.DB_PASSWORD }}

# Never log raw secret values  log only their presence or length
# GitHub's secret masking only works for values it knows about
# Derived values (base64 encoded, substrings) may not be masked

Secret Scanning

YAML
# GitHub Advanced Security: scans commits for accidentally committed secrets
# Enable in: Settings  Code security and analysis  Secret scanning

# Also add to local development with pre-commit hooks:
# .pre-commit-config.yaml:
# repos:
#   - repo: https://github.com/Yelp/detect-secrets
#     hooks:
#       - id: detect-secrets

# For .NET: TruffleHog or gitleaks in CI:
- name: Scan for secrets
  uses: trufflesecurity/trufflehog@main
  with:
    path: ./
    base: ${{ github.event.repository.default_branch }}
    head: HEAD
    extra_args: --debug --only-verified

Rotating Secrets

Secret rotation process:
  1. Create the new credential in the target service (new DB password, new API key)
  2. Update the GitHub secret: Settings → Secrets → {SECRET_NAME} → Update
  3. Run the CI pipeline to verify the new credential works
  4. Revoke the old credential in the target service
  5. Document the rotation (date, reason, who performed it)

Automation with OIDC: rotation becomes irrelevant — no stored secret to rotate.
  OIDC tokens are short-lived (under 10 minutes) and issued per workflow run.

For secrets that cannot use OIDC: use Azure Key Vault.
  App reads the secret from Key Vault at runtime.
  Rotate in Key Vault — no GitHub secret to update.
  Only the Key Vault URI is stored in GitHub (not a secret — just configuration).

Production issue I've seen: A developer added a debug step to a GitHub Actions workflow: run: cat appsettings.Production.json. The file contained a hardcoded connection string with the production database password (the secret had been committed months before they knew better). The workflow log was public — the repository was open source. GitHub's secret masking didn't catch it because the value wasn't registered as a secret. A bot found the password within 4 minutes. Lesson: secret masking only works for registered secrets. The fix for accidentally committed secrets is to immediately rotate them — rewriting git history is insufficient (it may already be cached or forked).


Key Takeaway

Use OIDC (Federated Identity) to authenticate GitHub Actions to Azure — no stored credentials, no rotation overhead. Scope environment secrets to deployment environments and protect production with manual approval gates. Never echo secret values in logs — log only presence or length. Enable GitHub's secret scanning to catch accidentally committed credentials. Prefer external secret stores (Key Vault) over GitHub secrets for runtime credentials — the app reads from Key Vault, GitHub only stores the vault URI.