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
# 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 credentialsOIDC — Keyless Authentication to Azure (Recommended)
# 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# 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 tokenEnvironment Protection Rules
# 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
# 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 maskedSecret Scanning
# 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-verifiedRotating 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.