DevOpsGitHub

GitHub Actions: Secrets, Environments, and OIDC Authentication

TT
TopicTrick Team
GitHub Actions: Secrets, Environments, and OIDC Authentication

GitHub Actions: Secrets, Environments, and OIDC Authentication

Credentials — API keys, database passwords, cloud access tokens — are the most sensitive assets in your CI/CD pipeline. A leaked secret in a GitHub Actions log can expose your entire production infrastructure. GitHub provides three layers of protection: encrypted secrets, deployment environments with approval gates, and OIDC (OpenID Connect) authentication that eliminates long-lived credentials entirely.

This guide covers all three, plus how to audit secret access and avoid the common mistakes that lead to credential exposure.


Encrypted Secrets

GitHub stores secrets encrypted at rest using libsodium. Secrets are never exposed in logs — GitHub automatically redacts them if they appear in output.

Secret Scopes

Secrets can be defined at three levels:

ScopeVisible toSet in
Repository secretWorkflows in that specific repositoryRepo → Settings → Secrets and variables → Actions
Environment secretWorkflows targeting that environmentRepo → Settings → Environments → [env] → Secrets
Organisation secretAll (or selected) repositories in the orgOrg → Settings → Secrets and variables → Actions

Creating Repository Secrets

Via GitHub UI:

  1. Repository → Settings → Secrets and variables → Actions
  2. Click New repository secret
  3. Enter name (convention: SCREAMING_SNAKE_CASE) and value
  4. Click Add secret

Via GitHub CLI:

bash
# Add a secret from stdin
echo "my-secret-value" | gh secret set DATABASE_URL

# Add a secret from a file
gh secret set PRIVATE_KEY < private-key.pem

# Add a secret to a specific environment
gh secret set PROD_DB_URL --env production

# List all secrets (names only — values are never shown)
gh secret list

Via GitHub API:

bash
# Secrets must be encrypted with the repo's public key before upload
PUBLIC_KEY=$(gh api repos/owner/repo/actions/public-key --jq '.key')
KEY_ID=$(gh api repos/owner/repo/actions/public-key --jq '.key_id')

# Encrypt with libsodium (requires sodium-plus or similar library)
ENCRYPTED=$(node -e "
  const sodium = require('sodium-native');
  const key = Buffer.from('$PUBLIC_KEY', 'base64');
  const value = Buffer.from('my-secret-value');
  const ciphertext = Buffer.allocUnsafe(value.length + sodium.crypto_box_SEALBYTES);
  sodium.crypto_box_seal(ciphertext, value, key);
  console.log(ciphertext.toString('base64'));
")

gh api repos/owner/repo/actions/secrets/MY_SECRET \
  --method PUT \
  --field encrypted_value="$ENCRYPTED" \
  --field key_id="$KEY_ID"

Using Secrets in Workflows

yaml
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy application
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
        run: ./scripts/deploy.sh

      # Secrets can also be passed as action inputs
      - uses: my-org/deploy-action@v1
        with:
          api-key: ${{ secrets.DEPLOY_API_KEY }}

Secret Redaction

GitHub automatically redacts secret values from logs. If your secret appears in output, GitHub replaces it with ***:

yaml
- run: echo "${{ secrets.MY_SECRET }}"
# Output in log: echo "***"

Limitation: GitHub only redacts exact matches. If your secret is base64-encoded or URL-encoded in output, it will not be redacted. Never log secrets in any form.


Deployment Environments

Environments add a layer of control between your workflow and your deployment targets. Each environment can have:

  • Its own secrets (separate staging and production credentials)
  • Required reviewers (humans who must approve before the deployment proceeds)
  • A wait timer (delay before deployment starts)
  • Branch protection (only certain branches can deploy to this environment)

Creating an Environment

  1. Repository → Settings → Environments → New environment
  2. Name it (e.g., production, staging, development)
  3. Configure protection rules

Environment Configuration

Required reviewers — specify teams or individuals who must approve the deployment:

text
Settings → Environments → production → Required reviewers
Add: @alice, @security-team

After the CI job completes but before the deployment job starts, GitHub sends a notification to the required reviewers. The deployment is paused until at least one approves. If rejected, the job is cancelled.

Deployment branch policy — restrict which branches can deploy:

text
Protected branches only → only branches with branch protection rules
Selected branches → specific branch name patterns (e.g., main, release/*)

Environment Secrets in Workflows

yaml
# .github/workflows/deploy.yml
jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment: staging           # Links to the staging environment
    steps:
      - name: Deploy to staging
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}   # staging environment's DATABASE_URL
          API_KEY: ${{ secrets.API_KEY }}             # staging environment's API_KEY
        run: ./deploy.sh staging

  deploy-production:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://app.example.com    # Shown in the deployment log and GitHub UI
    needs: deploy-staging
    steps:
      - name: Deploy to production
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}   # DIFFERENT secret value for production
        run: ./deploy.sh production

When deploy-production starts, GitHub checks:

  1. Is the workflow triggered from an allowed branch?
  2. Have all required reviewers approved?
  3. Has the wait timer elapsed?

If any check fails, the job is paused or cancelled.


OIDC: Keyless Authentication (The Modern Standard)

Traditional CI/CD stores cloud credentials as long-lived secrets:

yaml
# Old approach — a long-lived AWS access key stored as a secret
- uses: aws-actions/configure-aws-credentials@v4
  with:
    aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
    aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

Problems with long-lived credentials:

  • They can be rotated by accident or expire
  • They can be stolen if the secret is exposed
  • They are hard to audit (which workflow used this key, when?)
  • They persist even after the job finishes

OIDC eliminates stored credentials entirely. GitHub generates a short-lived JWT token for each workflow run. Your cloud provider validates this token directly with GitHub's OIDC provider. No credentials need to be stored in GitHub.

Setting Up OIDC with AWS

Step 1: Create an OIDC identity provider in AWS IAM:

bash
# One-time setup via AWS CLI
aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1

Step 2: Create an IAM role that trusts the GitHub OIDC provider:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:*"
        }
      }
    }
  ]
}

The sub condition restricts which repositories can assume this role. Use repo:my-org/my-repo:environment:production to restrict to a specific environment.

Step 3: Use OIDC in your workflow:

yaml
# .github/workflows/deploy.yml
permissions:
  id-token: write     # Required for OIDC — must be explicitly granted
  contents: read

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

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/github-actions-deploy
          aws-region: us-east-1
          # No access key or secret key — OIDC handles authentication

      - run: aws s3 sync ./dist s3://my-app-bucket/
      - run: aws ecs update-service --cluster production --service app --force-new-deployment

GitHub generates a fresh JWT for each job. The token is valid for the duration of the job only. There is nothing to rotate and nothing to steal.

OIDC with Azure

yaml
permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}       # Not a secret — just a reference
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
          # No client secret — OIDC handles authentication
      
      - uses: azure/webapps-deploy@v3
        with:
          app-name: my-app
          slot-name: production

OIDC with Google Cloud

yaml
permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: projects/123/locations/global/workloadIdentityPools/github/providers/github
          service_account: deploy@my-project.iam.gserviceaccount.com
          # No service account key JSON — OIDC handles authentication
      
      - uses: google-github-actions/deploy-cloudrun@v2
        with:
          service: my-service
          region: us-central1
          image: gcr.io/my-project/my-app:${{ github.sha }}

Organisation Secrets and Variables

For credentials used across multiple repositories (Slack webhooks, shared API keys, common configuration), define them at the organisation level.

Organisation Secrets

bash
# Set an organisation secret accessible to all repos
gh secret set SLACK_WEBHOOK_URL \
  --org my-org \
  --body "https://hooks.slack.com/services/..."

# Set an organisation secret accessible to specific repos only
gh secret set DATADOG_API_KEY \
  --org my-org \
  --repos "my-org/api-service,my-org/worker-service" \
  --body "dd-api-key-value"

Variables (Non-Secret Configuration)

For non-sensitive configuration that should be visible (environment names, feature flags, non-secret URLs), use Variables instead of Secrets. Variables are visible in workflow logs.

yaml
# Use variables for non-sensitive config
jobs:
  deploy:
    steps:
      - run: echo "Deploying to ${{ vars.AWS_REGION }}"
        env:
          CLUSTER_NAME: ${{ vars.ECS_CLUSTER_NAME }}      # Visible in logs
          DATABASE_URL: ${{ secrets.DATABASE_URL }}       # Redacted in logs

Auditing Secret Access

Who Can See Secrets

  • Secrets are never shown in the GitHub UI after creation
  • Only workflow runs that explicitly reference ${{ secrets.SECRET_NAME }} can access the value
  • GitHub audit logs record when secrets are created, updated, or deleted (not when they are accessed in workflows)

Checking for Secret Exposure

Common accidental exposure patterns:

yaml
# ❌ NEVER do this — even though GitHub redacts the value, this is bad practice
- run: echo "Database URL: ${{ secrets.DATABASE_URL }}"

# ❌ Avoid passing secrets to untrusted actions
- uses: some-random-third-party-action@v1
  with:
    secret: ${{ secrets.AWS_SECRET }}    # The action receives the raw value

# ❌ Be careful with `run` scripts that might log env vars
- run: env    # This prints ALL environment variables, including secrets passed via env:
  env:
    MY_SECRET: ${{ secrets.MY_SECRET }}

Secret Scanning

GitHub automatically scans repositories for accidentally committed secrets (API keys, tokens, certificates). Enable it at: Repository → Settings → Security → Secret scanning → Enable

For organisations, enable it at the org level to apply to all repositories automatically.


Frequently Asked Questions

Q: What happens if I accidentally commit a secret to the repository?

Act immediately: assume the secret is compromised and revoke it at the source (AWS console, Stripe dashboard, etc.). Generate a new credential and replace it. GitHub's secret scanning may have already detected it and sent an alert. Removing the commit from git history does not make a compromised secret safe — if it was ever in a public repository, it has likely been indexed.

Q: Can I read a secret value after I create it?

No. GitHub never shows secret values after creation, for any user including repository owners. This is by design. If you lose a secret, create a new one and update it. Store secrets in your team's password manager (1Password, Bitwarden) as the source of truth.

Q: Should I use OIDC or stored secrets for AWS authentication?

OIDC is strongly preferred for cloud provider authentication in 2026. It eliminates the credential rotation problem, provides better audit trails (you can see exactly which GitHub repository and branch assumed which AWS role), and removes the risk of long-lived credential leakage. Use stored secrets only for third-party services that do not support OIDC.

Q: How do I rotate a secret used by many workflows simultaneously?

For organisation secrets, update the value once in the organisation settings — all workflows pick it up automatically on their next run. For repository secrets, update each repository's secret. For OIDC with role-based access, there is nothing to rotate — the OIDC configuration is permanent and the short-lived tokens are generated fresh for each workflow run.


Key Takeaway

GitHub's three-layer secret management system — encrypted secrets, deployment environments, and OIDC authentication — provides defense in depth for your CI/CD pipeline. Encrypted secrets protect credentials at rest and in logs. Deployment environments enforce approval gates so no single developer can deploy to production alone. OIDC eliminates the biggest risk of all: long-lived credentials that can be stolen, forgotten, or over-scoped. For any cloud provider that supports OIDC (AWS, Azure, GCP), migrate away from stored access keys today — the security benefit is immediate and the setup takes under an hour.

Read next: Continuous Integration: Building a Bulletproof Pipeline →


Part of the GitHub Mastery Course — engineering the security.