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
mainorrelease/**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
productionenvironment - 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 |
Related Articles
- Securing GitHub Actions — the broader GitHub Actions security configuration that environments are part of
- GitHub Actions Self-Hosted Runner — hardening the runner infrastructure that executes environment-gated deployments
- OIDC Federation Hardening — configuring OIDC trust relationships that replace stored environment secrets
- GitHub Advanced Security Enterprise — the GHAS features that complement environment protection for supply chain security
- CD Promotion Gates and Approvals — broader continuous delivery promotion gate patterns across CI/CD platforms