Secure AWS CI/CD Pipelines: OIDC, IAM, Secrets & Scanning
A CI/CD pipeline has write access to your production infrastructure, runs automatically, and produces output that looks exactly like a legitimate deployment. That combination makes it one of the most valuable targets in your AWS account. The good news: the same controls that secure the rest of your AWS environment also secure your pipeline. This page walks through every major control, in the order you should implement them.
What is a secure AWS CI/CD pipeline?
A CI/CD pipeline is the automated process that takes code from a developer’s commit and moves it to a running application: building it, testing it, scanning it, and deploying it. A secure pipeline adds a control at each of those stages. Only verified, authorized code reaches production. No credentials are exposed along the way. Every action is logged.
Think of a secure pipeline like a factory assembly line with inspection checkpoints. At each checkpoint, the line stops if something fails. In an AWS pipeline, those checkpoints are IAM role boundaries, vulnerability scans, manual approval gates before production, and audit logs that record every action. Code only reaches production if it passes every checkpoint.
The core components you will configure are IAM roles that give the pipeline scoped permissions at each stage, OIDC or service roles to eliminate static credentials, Secrets Manager or Parameter Store for runtime secrets, a container image scanner before push, and CloudTrail for the audit trail.
How a secure pipeline works: stage by stage
Each stage in a production-ready pipeline has a specific security control assigned to it. Here is what happens at each step and what protects it.
Source
The pipeline starts when code is pushed to a branch. Branch protection rules ensure no code reaches main without a pull request and at least one reviewer approving it. This is the first checkpoint: a human must approve the change before automated execution begins.
Build
The build stage compiles code, installs dependencies, and produces a container image. The CodeBuild service role or GitHub Actions OIDC role at this stage should only have build permissions: read from S3, write to CloudWatch Logs, fetch parameters from Parameter Store. No deployment permissions at this stage.
Test
Automated tests run against the built artifact before promotion. Integration tests that need a database should pull credentials from Secrets Manager by ARN reference, not from hardcoded values or plaintext environment variables.
Scan
Before the container image is pushed to ECR, a vulnerability scanner runs against it. If critical CVEs are found, the build fails and the image is never pushed. This is where vulnerable images stop: before they ever reach a registry.
Approve
For production deployments, a manual approval gate holds the pipeline until a human explicitly authorizes the release. In CodePipeline this is a native approval action. In GitHub Actions this is a GitHub Environment with required reviewers. See Managing Environments in CI/CD for how to structure dev, staging, and production gates.
Deploy
The deploy stage assumes a separate IAM role, distinct from the build role, with the minimum permissions needed: push to ECR, update the ECS service, or trigger the Lambda update. Nothing more.
Audit
Every action in every stage is logged. CodeBuild logs go to CloudWatch Logs. IAM role assumptions, ECR pushes, ECS updates, and Secrets Manager reads all generate CloudTrail events. The audit trail must exist before you need it, not after an incident.
Core security controls
OIDC instead of long-lived access keys
Long-lived AWS access keys stored as CI secrets are the most common cause of AWS credential leaks. They never expire on their own. If one appears in a log file or git commit, it is immediately compromised.
OIDC eliminates the static credential entirely. GitHub Actions requests a short-lived JWT token from GitHub’s OIDC provider. AWS trusts that token and issues temporary STS credentials that expire when the job ends. There is nothing to rotate and nothing persists after the job finishes.
Think of OIDC like a day pass versus a permanent key card. A long-lived access key is a permanent key card: once issued, it works until someone cancels it. An OIDC token is a day pass: it grants access for the duration of the job, then expires automatically. If someone copies your day pass, it is already invalid by the time they try to use it.
Setting up OIDC for GitHub Actions requires two AWS resources and one workflow change:
1. Create the IAM Identity Provider (once per AWS account):
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea12. Create an IAM role with a trust policy scoped to your specific repository and branch:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012: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:your-org/your-repo:ref:refs/heads/main"
}
}
}
]
}3. Use the role in your workflow — no stored credentials needed:
permissions:
id-token: write # required to request the OIDC token from GitHub
contents: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsDeployRole
aws-region: us-east-1Scope the sub condition to the specific repository and branch, not a wildcard. The condition repo:your-org/your-repo:ref:refs/heads/main prevents feature branches and forks from assuming the production deployment role. See GitHub Actions for AWS for the full OIDC walkthrough including ECR login and ECS deployment steps.
Separate build and deploy roles
Most pipeline security problems trace back to a single overly broad IAM role shared across all stages. The build stage does not need ECS permissions. The deploy stage does not need to write test reports or read source artifacts. Give each stage its own role with only what it needs.
A deploy role that pushes an image and updates a single ECS service looks like this:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ECRPush",
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload"
],
"Resource": "arn:aws:ecr:us-east-1:123456789012:repository/my-app"
},
{
"Sid": "ECSUpdateService",
"Effect": "Allow",
"Action": [
"ecs:UpdateService",
"ecs:DescribeServices"
],
"Resource": "arn:aws:ecs:us-east-1:123456789012:service/my-cluster/my-service"
}
]
}This is the principle of least privilege applied to pipeline roles. Never attach AWS managed policies like AmazonECS_FullAccess to a pipeline role. That policy grants permission to delete ECS clusters and deregister task definitions, neither of which a deployment step ever needs.
Secrets Manager and Parameter Store for runtime secrets
CodeBuild environment variables are visible in the AWS console and in build logs if you print them. Never store a database password or API key as a plaintext variable. Reference secrets by ARN instead. CodeBuild fetches the value from AWS Secrets Manager at build start and injects it as an environment variable that is automatically masked in CloudWatch Logs:
# buildspec.yml
version: 0.2
env:
secrets-manager:
DB_PASSWORD: arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/db:password
API_KEY: arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/api-key:key
phases:
build:
commands:
- npm run integration-tests
# $DB_PASSWORD is available here and masked in logsThe CodeBuild service role needs secretsmanager:GetSecretValue on each specific secret ARN, not a wildcard resource. Granting access to * gives the pipeline access to every secret in the account. See Managing Secrets in CI/CD Pipelines for the full treatment including Parameter Store integration and runtime secret injection for ECS containers.
Lockfiles and action pinning
Supply chain attacks target your dependencies: npm packages, Python packages, and GitHub Actions. The attack pattern is straightforward. A popular package maintainer’s account is compromised, a backdoored version is published, and anyone whose pipeline pulls that dependency now runs attacker-controlled code under their pipeline’s IAM permissions.
For GitHub Actions, never pin to a version tag. Tags are mutable and can be redirected by the maintainer or an attacker. Pin to the specific commit SHA:
# Unsafe: the tag can be updated to point at any commit
- uses: actions/checkout@v4
# Safe: this exact commit SHA cannot change
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2Tags like @v4 look pinned but are not. A maintainer can push a new commit to the same tag at any time, and your pipeline will silently run the new code on the next build. Always pin to the full 40-character commit SHA. Many security-focused teams automate this with Dependabot or Renovate.
For application dependencies, use lockfiles (package-lock.json, poetry.lock, go.sum) and run npm ci instead of npm install in CodeBuild. npm ci installs from the lockfile exactly and fails if the lockfile is out of sync with package.json.
Container image scanning
Scan the container image before it is pushed to ECR. If the scanner finds critical CVEs, fail the build and do not push the image. Trivy is an open-source scanner that runs as a build step in CodeBuild:
# buildspec.yml: build, scan, then push
phases:
build:
commands:
- docker build -t my-app:$CODEBUILD_RESOLVED_SOURCE_VERSION .
post_build:
commands:
# Install Trivy using the official install script
- curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
# Fail the build if any CRITICAL CVEs are found
- trivy image --severity CRITICAL --exit-code 1 my-app:$CODEBUILD_RESOLVED_SOURCE_VERSION
# Push only if the scan passed
- docker push $ECR_REPO_URI:$CODEBUILD_RESOLVED_SOURCE_VERSIONAfter the image is in ECR, enable enhanced scanning (powered by Amazon Inspector). This continuously re-scans your images as new CVEs are published, so a vulnerability discovered months after the initial push also triggers an alert. See ECR Best Practices for the full configuration including lifecycle policies and repository access controls.
Branch protection and approval gates
Require that every change to main goes through a pull request with passing CI checks. In GitHub: Settings → Branches → Add branch protection rule, then enable “Require a pull request before merging” and “Require status checks to pass before merging.” Direct pushes to main are blocked, which means no unreviewed code can trigger a production deployment.
For production deployments specifically, add a manual approval gate. In GitHub Actions, use a GitHub Environment with required reviewers. In AWS CodePipeline, use a native approval action in the pipeline definition. The deploy job should not run automatically without sign-off.
CloudTrail and audit logging
Every pipeline-related API call generates a CloudTrail event: CodeBuild build start, IAM role assumption, ECR push, Secrets Manager fetch. Enable CloudTrail in every account where pipelines run. CodeBuild build logs go to CloudWatch Logs by default. Retain them for at least 90 days.
When something goes wrong, you need to be able to answer: what code was built, what image was pushed, and which secrets were accessed during which build. The audit trail needs to be there before the incident, not created in response to it. If your team uses policy as code, you can use AWS Config rules to automatically detect pipelines missing CloudTrail coverage or using overly broad IAM roles.
Never use env, printenv, or set in a buildspec phase. These commands dump every environment variable to CloudWatch Logs in plaintext, including secrets injected from Secrets Manager. Remove all debugging environment dumps before merging buildspec changes to your main branch.
Who this applies to
These controls apply to any team with an automated pipeline deploying to AWS. You do not need to be running containers or using a specific CI tool.
- GitHub Actions to AWS: Start with OIDC setup, SHA-pinned actions, and a scoped deployment role. The OIDC trust policy and workflow snippet above are your starting point. Read the full walkthrough in GitHub Actions for AWS Deployments.
- AWS CodeBuild and CodePipeline: CodeBuild authenticates via its IAM service role, so no OIDC setup is needed. Your security work is the service role policy: separate build and deploy roles, Secrets Manager references in buildspec.yml, and image scanning in the post_build phase.
- ECS, EKS, or Lambda deployments: All benefit from pre-push image scanning (for containers), scoped deploy roles, and runtime secret injection from Secrets Manager directly into ECS task definitions.
- Terraform or CloudFormation pipelines: IaC pipeline roles often end up very broad because Terraform needs a wide range of permissions to manage infrastructure. Enforce plan review before apply, use separate roles for plan and apply stages, and consider Service Control Policies to limit what those roles can actually do even if they are misconfigured.
Quick comparisons
OIDC vs long-lived access keys
| Factor | OIDC (recommended) | Long-lived access keys |
|---|---|---|
| Credential lifetime | Minutes — expires when the job ends | Years — never expires automatically |
| Rotation required | No — credentials are ephemeral | Yes — requires manual or automated rotation |
| Leak impact | Minimal — expires before it can be reused | High — valid until manually revoked |
| Stored where | Nowhere — generated per job | GitHub Secrets, CI environment, or .env file |
| Compatible with | GitHub Actions, GitLab CI, CircleCI, Buildkite | Any CI system |
| Setup complexity | One-time OIDC provider and role setup | Simple to add, ongoing rotation burden |
Secrets Manager vs Parameter Store
| Factor | AWS Secrets Manager | Parameter Store (SecureString) |
|---|---|---|
| Cost | $0.40 per secret per month plus API calls | Free (standard tier) |
| Automatic rotation | Yes — built-in for RDS, Redshift, DocumentDB | No — manual rotation only |
| Cross-account sharing | Yes, via resource-based policies | Limited — requires additional setup |
| Best for | Database credentials, rotating secrets, shared secrets | API keys, config values, non-rotating secrets |
| CodeBuild buildspec key | secrets-manager | parameter-store |
Production-readiness checklist
Review this before shipping a pipeline to production:
- OIDC configured for external CI systems; IAM service role with a scoped policy for CodeBuild
- OIDC trust policy
subcondition scoped to the specific repository and branch, no wildcards - Separate IAM roles for the build stage and the deploy stage
- No broad AWS managed policies attached to pipeline roles (
AmazonECS_FullAccess,AdministratorAccess) - All secrets referenced by ARN in buildspec.yml, no plaintext environment variables
- No
env,printenv, orsetcommands in any buildspec phase - GitHub Actions pinned to commit SHAs, not version tags
- Application dependencies use lockfiles; CI runs
npm cinotnpm install - Image scanner configured to fail the build on CRITICAL CVEs before push
- ECR enhanced scanning enabled for continuous post-push re-scanning
- Branch protection enabled on main — direct pushes blocked, PR and CI required
- Manual approval gate required before production deployments
- CloudTrail enabled in all accounts where pipelines run
- CodeBuild logs retained in CloudWatch Logs for at least 90 days
If this list feels long, start with the three highest-impact items: replace static access keys with OIDC (or confirm your service role is scoped correctly), move secrets into Secrets Manager, and enable branch protection on main. Those three alone eliminate the most common failure modes.
Common beginner mistakes
- Storing AWS access keys as CI secrets. Long-lived keys never expire and can leak through logs, misconfigured exports, or a compromised pull request. OIDC eliminates the static credential entirely: there is nothing to rotate and nothing to leak.
- One IAM role for all pipeline stages. When the build stage and deploy stage share a role, a compromised build step can also modify ECS services, delete ECR repositories, or assume other roles. Split them into two.
- OIDC trust policy scoped too broadly. A
subcondition ofrepo:your-org/:lets any repository in the organization assume the production deployment role. Always scope to the specific repository and branch. - Pinning GitHub Actions to version tags. Tags are mutable. A maintainer or attacker can move
v4to point at a completely different commit. A commit SHA is immutable. Always pin to the SHA. - Skipping image scanning. A 30-second Trivy scan before push is trivial. Deploying a container with a known critical CVE is not. Fail the build and fix the image.
- Printing environment variables in buildspec.yml.
envandprintenvin any build phase dump all environment variables including injected secrets to CloudWatch Logs in plaintext. Remove debugging dumps before merging. - No manual approval gate before production. Without an approval step, a merged pull request immediately deploys to production. One bad merge can cause an outage before anyone reacts.
Summary
- CI/CD pipelines have elevated IAM permissions and automated execution. Treat their security as seriously as application security.
- Use OIDC for external CI systems like GitHub Actions. AWS issues temporary STS credentials per job that expire when the job ends. There are no stored credentials to rotate or leak.
- Create separate least-privilege IAM roles for the build and deploy stages. Never attach broad managed policies like
AmazonECS_FullAccess. - Reference secrets by ARN in buildspec.yml using the
secrets-managerorparameter-storekeys. Never use plaintext environment variables for sensitive values. - Pin GitHub Actions to commit SHAs, not version tags. Tags are mutable and provide only the illusion of version control.
- Run an image scanner in the build phase and fail the build on critical CVEs before the image reaches ECR. Enable ECR enhanced scanning for continuous post-push monitoring.
- Enable branch protection on main, add a manual approval gate before production, enable CloudTrail, and retain CodeBuild logs for at least 90 days.
Frequently asked questions
Why are CI/CD pipelines a high-value attack target?
Pipelines have elevated IAM permissions to push container images, update ECS services, and modify infrastructure. An attacker who injects code into a pipeline can exploit those permissions to deploy malicious software or exfiltrate secrets, all under what looks like a legitimate automated deployment.
What is OIDC and why does it replace access keys in CI/CD?
OIDC (OpenID Connect) lets GitHub Actions or other CI systems prove their identity to AWS using a short-lived cryptographic token instead of a stored access key. AWS issues temporary STS credentials that expire when the job ends. There is nothing to rotate and nothing that persists after the job finishes.
Should I scan container images before or after pushing to ECR?
Both. Scan during the build phase to fail fast before the image is pushed at all. Then enable ECR enhanced scanning so new CVEs discovered after the initial push also trigger alerts.
Do CodeBuild pipelines also need OIDC?
No. CodeBuild runs inside AWS and authenticates through its IAM service role directly. OIDC is for external CI systems like GitHub Actions that need to assume an AWS role from outside your account. The goal is the same: the pipeline gets temporary, scoped credentials instead of a long-lived access key.
What is the minimum I should do before shipping a pipeline to production?
At minimum: replace any static access keys with OIDC or IAM service roles, scope IAM permissions to the specific resources the pipeline needs, store all secrets in Secrets Manager or Parameter Store, and enable CloudTrail. Image scanning and branch protection should follow immediately after.