GitHub Actions Environment Protection Rules and Secret Scoping

GitHub Actions Environment Protection Rules and Secret Scoping

Problem

GitHub Actions workflows deploy to production using secrets that unlock cloud access, registry credentials, and deployment keys. In the default configuration, these secrets are scoped at the repository level — available to any workflow in any branch that can trigger a run. A compromised workflow, a malicious action, a pull request from a fork targeting pull_request_target, or a supply chain attack on a referenced action can all access repository-level secrets.

GitHub Environments address this by gating secret access behind an explicit deployment context. A secret defined in the production environment is only available to a job that runs in that environment, and only when all protection rules for that environment are satisfied. This is a meaningful security boundary: it separates the credentials that deploy to production from the credentials that run tests, build images, and interact with staging.

The adoption gap is that most teams use repository-level secrets for everything and have never configured environments. The feature requires intentional setup — secrets must be moved from repository scope to environment scope, workflows must declare the environment, and protection rules must be configured. Teams that set up their pipelines before environments became robust (or teams that followed tutorials that pre-date the feature) rarely have this in place.

The specific risks from repository-level secrets for production credentials:

Any branch can access production secrets. A developer who creates a feature branch and runs a workflow — or who triggers workflow_dispatch — can access the same secrets used for production deployment. This is not just a policy concern; it means that a compromised dependency in any branch’s workflow can exfiltrate production cloud credentials.

Forks can access secrets via pull_request_target. pull_request_target runs with base branch permissions and accesses repository secrets. A forked PR that triggers this workflow can, with the right exploit, reach repository-level secrets. Environment protection breaks this path: even if the workflow runs, the job must pass environment protection rules before accessing environment secrets.

No deployment audit trail. Repository-level secret usage does not create a deployment record. Environment deployments create a record in the GitHub Deployments API — visible in the repository, auditable, and linked to a specific commit and actor. This is both a security benefit (audit trail) and an operational benefit (visible deployment history).

Custom protection rules. Since late 2023, GitHub supports custom deployment protection rules via GitHub Apps. These can enforce external approvals — checking a change management ticket, verifying a deploy freeze period is not active, requiring a security scan to have passed — before environment secrets are released to the workflow.

Target systems: GitHub repositories (cloud and GHES ≥3.9) using GitHub Actions for deployment; any workflow that deploys to production, staging, or other regulated environments; organisations where production cloud credentials are currently stored as repository-level secrets.


Threat Model

Adversary 1 — Compromised action in a feature branch workflow. Access level: a workflow runs in a feature branch; a referenced action (uses: some-org/some-action@v2) has been compromised via a supply chain attack. Objective: exfiltrate the AWS_SECRET_ACCESS_KEY stored as a repository secret. Without environments: succeeds. With environment-scoped production secrets: the feature branch workflow cannot access production environment secrets; only jobs explicitly targeting the production environment (and satisfying protection rules) can.

Adversary 2 — Pull request from fork via pull_request_target. Access level: a fork opens a PR that triggers a pull_request_target workflow. A vulnerability in the workflow allows the forked PR to influence the workflow’s behaviour. Objective: access repository secrets. Without environments: secrets are reachable. With environment protection requiring required reviewers: the environment gate blocks secret access regardless of the workflow vulnerability.

Adversary 3 — Developer triggering workflow_dispatch on arbitrary branch. Access level: developer with repository write access triggers a workflow manually from a feature branch. Objective: deploy to production or access production credentials outside the normal deployment flow. Without environments: possible if the workflow is triggered. With branch constraints on the production environment: only branches matching the configured pattern (e.g., main) can deploy to production.

Adversary 4 — Insider bypassing change management. Access level: engineer with direct repository access. Objective: deploy outside change management windows. Without custom protection rules: can deploy at any time. With custom protection rule checking a change management API: deployment is blocked outside approved windows.


Configuration / Implementation

Step 1 — Create environments and migrate secrets

Create environments via the GitHub UI (Settings → Environments) or via the API:

# Create environments via GitHub API
GH_TOKEN=$GITHUB_PAT  # Needs repo admin scope
ORG=your-org
REPO=your-repo

for env in staging production; do
  gh api repos/$ORG/$REPO/environments \
    --method PUT \
    -f name="$env" \
    --silent
  echo "Created environment: $env"
done

# List current repository secrets (these are the ones to migrate)
gh secret list --repo $ORG/$REPO

# List environment secrets (currently empty)
gh secret list --env production --repo $ORG/$REPO

Migrate secrets from repository scope to environment scope:

# Move production credentials from repo scope to production environment
# Step 1: read the current value (if you have access)
# Step 2: add to environment
# Step 3: remove from repo scope

# Add to environment
gh secret set AWS_ACCESS_KEY_ID \
  --env production \
  --repo $ORG/$REPO \
  --body "$(read -sp 'Value: '; echo $REPLY)"

gh secret set AWS_SECRET_ACCESS_KEY \
  --env production \
  --repo $ORG/$REPO \
  --body "$(read -sp 'Value: '; echo $REPLY)"

# Remove from repository scope (after verifying environment works)
gh secret delete AWS_ACCESS_KEY_ID --repo $ORG/$REPO
gh secret delete AWS_SECRET_ACCESS_KEY --repo $ORG/$REPO

Step 2 — Configure protection rules on the production environment

# Configure protection rules via API
gh api repos/$ORG/$REPO/environments/production \
  --method PUT \
  --field 'required_reviewers=[{"type":"Team","id":TEAM_ID}]' \
  --field 'wait_timer=0' \
  --field 'deployment_branch_policy={"protected_branches":false,"custom_branch_policies":true}'

# Add branch constraint: only 'main' can deploy to production
gh api repos/$ORG/$REPO/environments/production/deployment-branch-policies \
  --method POST \
  --field 'name=main' \
  --field 'type=branch'

Via the GitHub UI equivalents:

  • Required reviewers: select the team or individuals who must approve production deployments
  • Wait timer: add a delay (0–43,200 minutes) before deployment proceeds after trigger
  • Deployment branches: restrict to main or release/** patterns

Step 3 — Update workflows to declare environments

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

permissions:
  contents: read
  id-token: write    # For OIDC
  deployments: write # Required to create deployment records

jobs:
  deploy-staging:
    # Staging environment — no reviewer required, all branches allowed
    environment:
      name: staging
      url: https://staging.example.com
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683

    - name: Configure AWS credentials (staging)
      uses: aws-actions/configure-aws-credentials@xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
      with:
        role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE }}  # From staging environment
        aws-region: us-east-1

    - name: Deploy to staging
      run: ./scripts/deploy.sh staging

  deploy-production:
    needs: deploy-staging
    # Production environment — requires reviewer approval + main branch only
    environment:
      name: production
      url: https://example.com
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683

    - name: Configure AWS credentials (production)
      uses: aws-actions/configure-aws-credentials@xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
      with:
        # This secret is ONLY available after environment protection rules pass
        role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE }}  # From production environment
        aws-region: us-east-1

    - name: Deploy to production
      run: ./scripts/deploy.sh production

Key: ${{ secrets.AWS_DEPLOY_ROLE }} resolves to the environment-scoped secret when environment: production is declared. If a malicious workflow omits the environment declaration and references the same secret name, it gets the repository-level secret (which should now be empty after migration).

Step 4 — Implement OIDC instead of long-lived secrets in environments

For AWS, Azure, and GCP, OIDC federation replaces stored credentials entirely. Environment-scoped OIDC configurations ensure the trust relationship is tied to a specific environment:

# AWS OIDC role — trust only the production environment from this repo
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::ACCOUNT:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          // Only allow this specific repo + environment + branch combination
          "token.actions.githubusercontent.com:sub": 
            "repo:your-org/your-repo:environment:production:ref:refs/heads/main"
        }
      }
    }
  ]
}

This OIDC trust condition means the IAM role cannot be assumed by:

  • A workflow from a different repository
  • A workflow not running in the production environment
  • A workflow triggered from a branch other than main

No stored secrets are needed at all — the OIDC token is generated on-demand by GitHub Actions and is short-lived.

Step 5 — Build a custom protection rule (GitHub App)

For organisations that need external approval workflows (ServiceNow, Jira, PagerDuty):

// custom-protection-rule/index.js
// GitHub App that implements a custom deployment protection rule
// Checks a change management API before allowing deployment to proceed

const { Octokit } = require("@octokit/rest");
const express = require("express");
const app = express();

app.post("/deployment_protection_rule", async (req, res) => {
  const { deployment_callback_url, environment, deployment } = req.payload;
  const sha = deployment.sha;
  const ref = deployment.ref;
  
  // Check your change management system
  const approved = await checkChangeManagement({
    repository: req.payload.repository.full_name,
    environment: environment.name,
    sha,
    ref,
    actor: deployment.creator.login,
  });
  
  const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
  
  if (approved) {
    // Allow deployment to proceed — secrets are released
    await fetch(deployment_callback_url, {
      method: "POST",
      headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` },
      body: JSON.stringify({ state: "approved", comment: "Change approved in ServiceNow" })
    });
  } else {
    // Block deployment — secrets remain inaccessible
    await fetch(deployment_callback_url, {
      method: "POST",
      headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` },
      body: JSON.stringify({
        state: "rejected",
        comment: "No approved change request found for this deployment window"
      })
    });
  }
  
  res.status(200).send();
});

Register this App in your GitHub organisation and configure it as a custom protection rule on the production environment.

Step 6 — Audit environment protection configuration

#!/bin/bash
# audit-environments.sh — check all repos have environment protection

ORG=your-org

gh api orgs/$ORG/repos --paginate | jq -r '.[].name' | while read -r repo; do
  # Check if repo has workflows that deploy
  if gh api repos/$ORG/$repo/contents/.github/workflows 2>/dev/null | \
    jq -r '.[].name' | grep -qi "deploy\|release\|publish"; then
    
    # Check for production environment
    env_data=$(gh api repos/$ORG/$repo/environments 2>/dev/null)
    prod_env=$(echo "$env_data" | jq -r '.environments[] | select(.name == "production")' 2>/dev/null)
    
    if [[ -z "$prod_env" ]]; then
      echo "FINDING: $repo has deployment workflow but no production environment"
    else
      # Check protection rules
      reviewers=$(echo "$prod_env" | jq '.protection_rules[] | select(.type == "required_reviewers") | .reviewers | length' 2>/dev/null)
      branch_policy=$(echo "$prod_env" | jq '.deployment_branch_policy' 2>/dev/null)
      
      if [[ "$reviewers" == "0" || -z "$reviewers" ]]; then
        echo "WARNING: $repo/production environment has no required reviewers"
      fi
      
      if [[ "$branch_policy" == "null" ]]; then
        echo "WARNING: $repo/production environment has no branch constraints"
      fi
    fi
  fi
done

Expected Behaviour

Signal Before environments After environments
Feature branch workflow accessing AWS_SECRET_ACCESS_KEY Succeeds (repo-level secret) ${{ secrets.AWS_SECRET_ACCESS_KEY }} is empty — secret only in production environment
Production deployment without reviewer approval Triggers immediately Paused at environment gate; notification sent to required reviewers
Workflow from fork accesses production secret Possible via pull_request_target + vuln Blocked — fork workflows cannot satisfy environment protection rules
Deployment from non-main branch Succeeds Blocked by branch constraint on production environment
Production deployment audit trail Not visible in UI Visible in repository Deployments tab with actor, SHA, timestamp

Verification:

# Test: workflow on feature branch cannot access production secret
# Create a test workflow that just tries to echo the secret
# If the value is empty, environment scoping is working

gh workflow run test-secret-access.yml --ref feature/test-branch
# Check the run logs — the secret should show as "***" but the env var should be empty
# or the job should fail at the environment gate

Trade-offs

Aspect Benefit Cost Mitigation
Required reviewer for production Prevents unilateral deployments; creates audit trail Adds latency to deployments; reviewer must be available Define an auto-approve policy for low-risk changes (documentation updates); use a small on-call rotation for approval
OIDC instead of stored secrets No long-lived credentials; token scope tied to environment + branch Requires AWS/Azure/GCP OIDC configuration; more complex initial setup Template the OIDC role trust policy; create a platform team Terraform module
Branch constraints on production Prevents arbitrary branches from deploying Complicates hotfix workflows that branch from main Add hotfix/** to the allowed branch patterns; require PR to main first
Custom protection rules Integrates external approvals (change management) Requires a GitHub App to implement; adds external dependency Use GitHub’s built-in required reviewers for most cases; custom rules only for regulated environments requiring change management integration

Failure Modes

Failure Symptom Detection Recovery
Production deployment hangs waiting for reviewer Deployment paused indefinitely; no auto-timeout GitHub deployment status shows “Waiting”; team not notified Configure Slack/email notifications for pending reviews; set a 24-hour review timeout with auto-reject
Secret migrated to environment but workflow not updated Workflow runs but ${{ secrets.PROD_KEY }} is empty; deployment fails Workflow fails with auth error; logs show empty value Verify workflow declares environment: production; verify secret is in the correct environment
OIDC trust condition too narrow blocks hotfix Hotfix branch deployment fails with sts:AssumeRoleWithWebIdentity denied AWS CloudTrail shows access denied; workflow fails Update the OIDC trust condition to include hotfix/** branches; or merge hotfix to main first
Custom protection rule app is unavailable All production deployments blocked; approval requests time out Deployment stays in “Waiting” indefinitely; no approval/rejection received Configure a fallback with GitHub’s built-in required reviewers; implement health checks on the protection rule app