Introduction
Most CI/CD pipelines start with a simple goal: get code from a developer’s machine into production as fast as possible. Along the way, someone creates a service account, grants it broad permissions, stores the credentials as a pipeline secret, and moves on. It works. Builds pass, deployments succeed, and nobody thinks about it again — until an attacker compromises that pipeline and discovers they have the keys to the entire kingdom.
The problem is not that teams are careless. The problem is that CI/CD tooling makes it easy to grant excessive privilege and hard to apply fine-grained controls. The default path in most platforms is a single identity with broad access running every stage of the pipeline. Security principles like separation of duties (SoD) and least privilege feel like bureaucratic overhead when you’re trying to ship features.
They are not. They are engineering controls that limit blast radius when — not if — something goes wrong. This guide explains how to apply both principles to CI/CD pipelines in a way that strengthens your security posture without destroying delivery velocity.
Why Pipelines Accumulate Privilege
Before diving into solutions, it is worth understanding how pipelines end up with excessive permissions in the first place. The pattern is remarkably consistent across organizations.
The single service account problem
It starts with a single service account — often named something like ci-deployer or pipeline-bot — that is created during initial CI/CD setup. This account needs to pull code, so it gets repository read access. Then it needs to push Docker images, so it gets registry write access. Then it needs to deploy to staging, then production, then manage infrastructure. Within weeks, this one identity can build, test, deploy, and access production data. It has become a skeleton key.
God tokens
Closely related is the “god token” — a personal access token or API key with read/write access to everything. These tokens are often created by an administrator during setup, stored as a CI/CD variable, and never rotated. They typically outlive the person who created them, and nobody remembers exactly what permissions they carry.
Convenience over security
Organizations frequently use a single runner pool for all environments. The same machine that runs unit tests on untrusted pull requests from forks also has network access to production infrastructure. The reasoning is straightforward: maintaining separate runner pools is operationally expensive. But this convenience means that a malicious pull request could potentially access production credentials.
Lack of identity boundaries between stages
Most pipelines execute all stages under the same identity. The build stage, the test stage, the deploy stage — they all share the same service account, the same secrets, and the same network access. There is no boundary between “compiling code” and “pushing to production.” From a security perspective, these are fundamentally different trust levels that should never share an identity.
Separation of Duties in CI/CD
Separation of duties is a control design principle that ensures no single entity has enough access to complete a critical process alone. In CI/CD, this means deliberately splitting pipeline operations across different identities, approvals, and trust boundaries.
Core principles for pipeline SoD
- No single identity should build AND deploy to production. The identity that compiles code and produces artifacts should not be the same identity that pushes those artifacts to production infrastructure.
- Code authors should not approve their own deployments. The person who writes code should not be the sole approver of that code reaching production. At minimum, a second human must be involved.
- Pipeline definitions should be protected from the code they process. The workflow files that define how code is built and deployed should not be modifiable by the same process that runs application code.
- Build artifacts should be immutable once produced. Once a build stage produces an artifact, no subsequent stage should be able to modify it. The artifact that was tested is the artifact that gets deployed.
Mapping SoD to pipeline stages
A well-designed pipeline maps separation of duties to discrete stages, each with its own identity and permissions:
- Build — Compiles code, resolves dependencies, produces artifacts. Requires read access to source code and dependency registries. No access to deployment targets.
- Test — Runs unit tests, integration tests, security scans. Requires read access to artifacts and test infrastructure. No access to production secrets.
- Sign — Cryptographically signs artifacts that pass all checks. Requires access to signing keys but nothing else. This stage acts as a gate.
- Stage — Deploys to a staging environment for final validation. Requires write access to staging only. No production credentials available.
- Deploy — Promotes the signed, tested artifact to production. Requires write access to production, gated behind manual approval. Different identity from build.
Each stage boundary is a trust boundary. Credentials do not flow between stages unless explicitly granted.
Least Privilege Patterns
Least privilege means granting only the minimum permissions required for a specific task, for the shortest time necessary. In CI/CD, this translates to concrete patterns that vary by platform.
GitHub Actions: per-job permissions
GitHub Actions provides a permissions block that controls the scopes of the automatically-generated GITHUB_TOKEN. By default, this token has broad read/write access. You should always restrict it.
Set restrictive defaults at the workflow level and grant additional permissions only to specific jobs that need them:
# .github/workflows/deploy.yml
name: Build and Deploy
# Restrict default permissions for ALL jobs in this workflow
permissions:
contents: read
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
# This job inherits the workflow-level permissions: contents read only
steps:
- uses: actions/checkout@v4
- run: make build
- uses: actions/upload-artifact@v4
with:
name: app-binary
path: dist/
security-scan:
needs: build
runs-on: ubuntu-latest
permissions:
security-events: write # Only this job can write security findings
contents: read
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/analyze@v3
deploy-staging:
needs: [build, security-scan]
runs-on: ubuntu-latest
environment: staging # Scoped to staging secrets only
permissions:
contents: read
id-token: write # OIDC token for cloud auth — no static credentials
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/staging-deployer
aws-region: us-east-1
- run: ./scripts/deploy.sh staging
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production # Requires manual approval + different secrets
permissions:
contents: read
id-token: write
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/production-deployer
aws-region: us-east-1
- run: ./scripts/deploy.sh production
Key points in this configuration: each job declares only the permissions it needs, deployment jobs use OIDC for short-lived credentials instead of static secrets, and staging and production use different IAM roles with different access levels.
GitLab CI: protected variables and runners
GitLab CI offers a different but equally powerful set of controls for implementing least privilege:
# .gitlab-ci.yml
stages:
- build
- test
- deploy-staging
- deploy-production
build:
stage: build
tags:
- shared-runners # Non-privileged runners for build
script:
- make build
artifacts:
paths:
- dist/
test:
stage: test
tags:
- shared-runners
script:
- make test
dependencies:
- build
deploy-staging:
stage: deploy-staging
tags:
- deploy-runners # Dedicated runners with network access to staging
environment:
name: staging
script:
- ./scripts/deploy.sh staging
# CI_JOB_TOKEN is automatically scoped to this project
# Staging secrets are only available on protected branches
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
deploy-production:
stage: deploy-production
tags:
- production-runners # Isolated runners with production network access
environment:
name: production
script:
- ./scripts/deploy.sh production
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual # Requires manual approval
allow_failure: false # Block the pipeline if not approved
In GitLab, use protected variables so that production credentials are only available to protected branches and tags. Use protected runners to ensure that production deployment jobs only execute on hardened, isolated infrastructure. Mark production secrets as masked to prevent accidental exposure in logs.
Per-stage service accounts with scoped IAM roles
Beyond the CI platform itself, apply least privilege at the cloud provider level. Each pipeline stage should authenticate as a different service account with narrowly scoped permissions:
- Build stage: Read-only access to source repositories and dependency registries. Write access to the artifact store (e.g., S3 bucket, container registry) but nothing else.
- Test stage: Read-only access to artifacts. Permission to create and destroy ephemeral test infrastructure, but no access to staging or production.
- Deploy stage: Write access to the specific deployment target (e.g., a single Kubernetes namespace, a specific ECS service). No access to other environments or services.
Short-lived credentials via OIDC
Static credentials stored as CI/CD secrets are a liability. They do not expire, they are difficult to rotate, and they are attractive targets for attackers. The modern approach is to use OIDC federation so that your CI/CD platform exchanges a short-lived, platform-signed token for temporary cloud credentials:
- GitHub Actions can assume AWS IAM roles, GCP service accounts, or Azure managed identities using the
id-token: writepermission — no stored secrets required. - GitLab CI supports OIDC natively through its
CI_JOB_JWTorid_tokenskeyword, allowing the same pattern. - These credentials typically last 15-60 minutes and are scoped to the specific job that requested them.
If you are still using static access keys in your pipeline secrets, migrating to OIDC should be a high-priority initiative.
Protecting Pipeline Definitions
Pipeline-as-code is powerful, but it introduces a subtle trust problem: if an attacker can modify the pipeline definition, they control how code is built, tested, and deployed. Protecting pipeline configuration files is just as important as protecting the application code itself.
CODEOWNERS for workflow files
Use a CODEOWNERS file to require specific teams to review changes to CI/CD configuration:
# .github/CODEOWNERS
/.github/workflows/ @your-org/platform-security
/.gitlab-ci.yml @your-org/platform-security
/Jenkinsfile @your-org/platform-security
/terraform/ @your-org/infrastructure
This ensures that no one can modify pipeline definitions without approval from the team responsible for CI/CD security. Combine this with branch protection rules that require CODEOWNERS approval.
Branch protection on pipeline config directories
Enforce branch protection rules that prevent direct pushes to branches containing pipeline definitions. At minimum:
- Require pull request reviews before merging changes to workflow files.
- Require status checks to pass (including security scans of the workflow changes themselves).
- Disable force pushes to protected branches.
- Require signed commits for changes to pipeline configuration.
Immutable pipeline templates
Both GitHub Actions and GitLab CI support referencing pipeline definitions from external, centrally managed repositories:
GitHub Actions reusable workflows:
# In your repository's workflow, reference a centrally managed template
jobs:
deploy:
uses: your-org/shared-workflows/.github/workflows/secure-deploy.yml@v2.1.0
with:
environment: production
artifact-name: app-binary
secrets: inherit
GitLab CI includes from protected repos:
# .gitlab-ci.yml
include:
- project: 'platform-team/ci-templates'
ref: 'v3.0.0'
file: '/templates/secure-deploy.yml'
# Local jobs can use templates but cannot override protected stages
deploy-production:
extends: .secure-deploy-template
variables:
TARGET_ENV: production
By pinning to specific versions (tags or commit SHAs) and restricting who can modify the template repository, you ensure that individual teams cannot alter the security controls baked into the deployment process.
Preventing self-modifying pipelines
A pipeline should never be able to modify its own definition. Watch for these patterns:
- Pipeline steps that write to
.github/workflows/or.gitlab-ci.ymland commit the changes. - Dynamic pipeline generation that pulls configuration from untrusted sources.
- Pipeline variables that can override security-critical settings like which runner to use or which environment to deploy to.
If you need dynamic pipeline behavior, use parameterized templates with a fixed set of allowed inputs rather than allowing arbitrary modification of pipeline logic.
Deployment Controls
Deployment controls are the gates between pipeline stages that enforce human oversight and policy compliance. They are where separation of duties becomes tangible.
Required reviewers and manual approvals
Production deployments should require explicit approval from someone other than the code author. Both major platforms support this natively:
- GitHub Environments allow you to configure required reviewers. When a workflow job references an environment with protection rules, the pipeline pauses until an authorized reviewer approves.
- GitLab Protected Environments restrict which users or groups can trigger deployments to specific environments. Combined with
when: manual, this creates an approval gate.
GitHub Environments with protection rules
GitHub Environments are a powerful mechanism for deployment controls. Configure them with:
- Required reviewers: Specify individuals or teams who must approve before the job runs. Use at least two reviewers for production.
- Wait timer: Add a delay between approval and execution, giving time to catch mistakes or coordinate with change windows.
- Deployment branches: Restrict which branches can deploy to the environment. Production should only accept deployments from
mainorrelease/*. - Environment secrets: Store credentials at the environment level, not the repository level. This ensures staging secrets are not available to production jobs and vice versa.
Deployment freezes and change windows
Implement deployment freezes during critical business periods (e.g., Black Friday, end-of-quarter) by:
- Using scheduled environment protection rules that automatically block deployments during defined windows.
- Requiring additional approvals during freeze periods rather than blocking entirely — emergency fixes should still be possible with elevated oversight.
- Logging all freeze overrides for audit purposes.
Canary and progressive rollout as a control mechanism
Progressive deployment strategies are not just an availability concern — they are a security control. If a compromised artifact makes it past all other checks, a canary deployment limits the blast radius:
- Deploy to 1-5% of traffic first with automated health checks.
- Require a second manual approval to proceed beyond the canary stage.
- Automate rollback if error rates or latency exceed thresholds.
- Treat the canary stage as a separate environment with its own approval requirements.
Audit and Accountability
Separation of duties and least privilege are only effective if you can verify that they are being followed. Audit and accountability close the loop by providing evidence of who did what, when, and why.
Pipeline execution logs as audit trails
CI/CD platforms maintain detailed logs of every pipeline execution. Treat these logs as security-relevant audit data:
- Retain pipeline logs for at least 90 days (longer if your compliance framework requires it).
- Export logs to a centralized, tamper-resistant log store (e.g., SIEM, CloudWatch Logs, or a dedicated S3 bucket with object lock).
- Ensure logs capture which identity triggered the pipeline, which secrets were accessed (but not their values), and which approvals were granted.
Tying deployments to commits and approvers
Every production deployment should be traceable to:
- The specific Git commit SHA that was deployed.
- The pull request that introduced the change, including all reviewers and approvers.
- The pipeline run ID and the person who approved the deployment gate.
- The artifact digest (container image SHA, binary hash) that was deployed.
This traceability chain should be automated. If someone asks “what is running in production and who approved it,” the answer should be available in seconds, not hours.
Cloud audit logs for pipeline-initiated changes
Pipeline actions leave traces in cloud provider audit logs. Correlate these with your CI/CD logs:
- AWS CloudTrail records every API call made by your pipeline’s assumed IAM role. Cross-reference the pipeline run ID with the CloudTrail
userIdentity.sessionContextto tie cloud changes back to specific pipeline executions. - GCP Cloud Audit Logs provide similar traceability for service account actions.
- Azure Activity Logs capture resource modifications made by pipeline service principals.
Alerting on privilege escalation or policy bypass
Set up alerts for events that indicate your controls are being circumvented:
- A pipeline accessing secrets it should not need (e.g., build stage accessing production database credentials).
- Changes to branch protection rules or CODEOWNERS files.
- Modifications to IAM policies attached to pipeline service accounts.
- Pipeline runs triggered from unprotected branches deploying to protected environments.
- Deployment approvals granted by the same person who authored the code.
Feed these alerts into your security operations workflow and treat them with the same urgency as production incidents.
Common Anti-Patterns
Knowing what to avoid is just as important as knowing what to implement. These are the anti-patterns we see most frequently in CI/CD security assessments.
Single admin token for all CI/CD operations
A personal access token with admin scope, created by a senior engineer, stored as a repository secret, and used by every job in every pipeline. When that engineer leaves the organization, nobody revokes the token because nobody knows what will break. This is the single most common and most dangerous anti-pattern.
Fix: Replace with per-stage service accounts using OIDC federation. No static tokens, no shared identities.
Disabling branch protection “temporarily”
A deployment is blocked because a required check is failing. Someone disables branch protection to push directly to main, intending to re-enable it later. They forget, or they re-enable it but miss a setting. Meanwhile, the unprotected window allowed a direct push that bypassed code review.
Fix: Never disable branch protection. If a required check is failing, fix the check or use an emergency process that requires multiple approvals and creates an audit trail.
Shared runners between production and PR workloads
Pull request pipelines from forks run on the same infrastructure that has network access to production systems. A malicious PR can exfiltrate secrets, access internal services, or pivot to production infrastructure.
Fix: Use separate runner pools. Untrusted workloads (PRs, especially from forks) run on ephemeral, isolated runners with no access to sensitive environments. Production deployment runners are restricted to protected branches only.
Manual SSH access when pipelines fail
When a deployment pipeline fails, an engineer SSHs directly into a production server and deploys manually. This bypasses every control in the pipeline: code review, automated testing, artifact signing, deployment approval, and audit logging.
Fix: Invest in pipeline reliability so that manual intervention is rarely needed. When it is needed, use a break-glass procedure that requires multiple approvals, creates an audit trail, and triggers a post-incident review.
Conclusion
Separation of duties and least privilege are not bureaucratic overhead imposed by a compliance team. They are engineering controls that directly reduce the blast radius of security incidents in your software delivery pipeline.
A compromised build step with least privilege can produce a malicious artifact — but it cannot deploy that artifact to production. A compromised deployment credential scoped to staging cannot reach production. A malicious pull request processed by an isolated runner cannot exfiltrate production secrets. Each control limits what an attacker can achieve at every stage.
Start by auditing your current pipeline permissions. Identify every service account, every stored credential, and every secret your pipeline can access. Map each one to the specific stage and task that needs it. Then begin reducing scope: replace static credentials with OIDC, split single service accounts into per-stage identities, add approval gates for production deployments, and protect your pipeline definitions with CODEOWNERS and branch protection.
You do not have to do everything at once. Each incremental improvement reduces risk. But do start — because your CI/CD pipeline is likely the most privileged, least scrutinized system in your entire infrastructure.