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.
- Secrets are like your passport and cash β you lock them in the hotel safe. Nobody can see what's inside, not even housekeeping. When you need them, you unlock the safe, use them, and put them back. If someone asks "what's in the safe?", the hotel says "I can't tell you." That's how GitHub secrets work β encrypted, invisible even to admins after creation, and automatically masked in logs.
- Variables are like the items on your desk β your itinerary, a map, your conference badge. Anyone who walks in can see them, and that's fine because they're not sensitive. Variables store non-secret config like API base URLs or feature flags.
- Environment secrets are like having different safes in different rooms. The staging room has test API keys, the production room has real ones. When you check into a room (deploy to an environment), you only get that room's safe.
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
- Go to your repository β Settings β Secrets and variables β Actions
- Click New repository secret
- Enter a name (e.g.,
ACR_PASSWORD) and the secret value - Click Add secret
Using Secrets in Workflows
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..."
- 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 isUPPER_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
- Go to Settings β Environments β New environment
- Name it (e.g.,
production) - Add protection rules: required reviewers, wait timers, branch restrictions
- Add environment-specific secrets and variables
Using Environment Secrets
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
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
- All repositories β every repo in the org can access the secret
- Private repositories β only private repos can access it (excludes public/internal repos)
- Selected repositories β you explicitly pick which repos get access
Creating Organization Secrets
Go to your organization β Settings β Secrets and variables β Actions β New organization secret. Choose the visibility policy and save.
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
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 }}"
| Use Secrets | Use Variables |
|---|---|
| Passwords, tokens, API keys | API base URLs, feature flags |
| Database connection strings | Environment names (staging, prod) |
| Private keys, certificates | Version numbers, build configs |
| Registry credentials | Non-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
βββββββββββββββββββββββ 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.
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/
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.
- 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
# β 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)
# β
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
| Scope | Precedence | Visibility | Best For |
|---|---|---|---|
| Environment | π₯ Highest (wins) | Jobs referencing that environment only | Per-stage credentials (staging vs production keys) |
| Repository | π₯ Medium | All workflows in the repo | Repo-specific credentials (Codecov token, ACR password) |
| Organization | π₯ Lowest (overridden) | All/private/selected repos in the org | Shared 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
${{ 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
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.
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.
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.
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.
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
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
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
- Created federated credentials in Azure AD for each repo, scoped to
mainbranch andproductionenvironment - Converted secrets to variables β client ID and tenant ID moved from
secrets.tovars.(they're not sensitive with OIDC) - Removed client secrets entirely β no more
AZURE_CLIENT_SECRETin any repo - Added
permissions: id-token: writeto deployment jobs - Switched from
azure/login@v1toazure/login@v2with OIDC parameters
Results
- 120 β 0 stored credentials β nothing to rotate, nothing to leak
- Zero rotation-related outages since migration β no more expired secrets
- Tokens live for 10 minutes β even if leaked, they expire before they can be exploited
- Audit trail β Azure logs show exactly which repo and workflow assumed the identity
- Security team reclaimed 2 days/quarter previously spent on manual secret rotation
π Summary
- Repository secrets store sensitive values encrypted at rest, masked in logs, and inaccessible after creation
- Environment secrets scope credentials to specific deployment targets (staging, production) with optional protection rules
- Organization secrets share credentials across repos with configurable visibility policies β update once, apply everywhere
- Variables (
vars.) store non-sensitive config β visible in logs, readable from the UI, ideal for URLs and feature flags - Precedence: Environment > Repository > Organization β the most specific scope always wins
- OIDC eliminates stored credentials entirely β short-lived tokens, no rotation, auditable trust chain
- Secrets are not available in fork PRs β this is a security feature, not a bug
- Never embed secrets in URLs or concatenate them into strings that bypass masking
- Use
permissions: id-token: writeat the job level for OIDC, and scope cloud trust policies to specific repos and branches - Prefer OIDC over stored credentials for any cloud provider that supports it β it's more secure and less operational burden