CI/CD deploy permissions: split build and deploy identities safely
For developers running CI/CD who need production deploys without handing broad cloud admin to every build job. This guide shows how to separate the identity that compiles/tests code from the identity that can deploy, with concrete trust policies, token exchange flow, and the failure modes you will actually debug.
TL;DR — Don’t let the same CI job identity both build untrusted code and hold production deploy rights. Give the build job a low-privilege identity, then allow it to mint or assume a separate short-lived deploy identity only after branch/tag/environment checks; the most common fix is OIDC federation from your CI runner into cloud IAM plus a second role scoped only to deployment actions. Reading time: ~7 min
What it is and where it sits
This pattern separates two concerns that are often collapsed into one dangerous credential:
- Build identity: used by CI to fetch dependencies, run tests, build artifacts, publish to an artifact store.
- Deploy identity: used only to change runtime infrastructure or release a previously built artifact into an environment.
If your pipeline can run code from pull requests, feature branches, or compromised dependencies, then the build phase is the wrong place to expose production-capable credentials. The build job is where arbitrary code execution is most likely. The deploy phase should use a different identity, with narrower permissions, shorter lifetime, and stronger issuance conditions.
What this replaces:
- Long-lived cloud keys stored as CI secrets.
- One “ci-service-account” with both artifact-publish and production-admin rights.
- SSH private keys shared across all jobs to reach production hosts.
Where it lives in the architecture:
- In your CI system: the runner/job gets an ephemeral identity token from the CI platform.
- In your IAM layer: a trust policy decides whether that token may assume a deploy role.
- In your artifact/release flow: deploy consumes an immutable artifact built earlier, rather than rebuilding from source during deploy.
View as text
[Developer] ---- 1. git push/tag ----> [Git Repo] [Git Repo] ---- 2. start pipeline ----> [CI Build Job] [CI Build Job] ---- 3. build/test/publish ----> [Artifact Store] [CI Build Job] ---- 4. OIDC token ----> [IAM Trust Policy] [IAM Trust Policy] ---- 5. short-lived creds ----> [Deploy Role] [Deploy Role] ---- 6. deploy artifact ----> [Prod Env] [Prod Env] ---- 7. pull/release ----> [Artifact Store]
The important boundary is between steps 3 and 4. The build job may be able to request deploy credentials, but only if the IAM trust policy accepts the job context: specific repository, branch, tag pattern, workflow name, environment approval, or equivalent claims.
How it actually works
One realistic example: GitHub Actions builds a container image, pushes it to a registry, then assumes a cloud deploy role via OIDC to update a Kubernetes deployment. The build identity can push to the registry but cannot touch the cluster. The deploy identity can update only one namespace in one cluster and cannot read source secrets.
Step 1: Build job runs with low privilege
The runner checks out code, runs tests, and builds registry.example.com/payments:git-<sha>. It authenticates to the registry with credentials scoped to push on one repository, not cluster admin.
Typical commands:
docker build -t registry.example.com/payments:git-$GITHUB_SHA .
docker push registry.example.com/payments:git-$GITHUB_SHA
If this job is compromised by malicious build scripts, the blast radius is limited to the artifact path you granted.
Step 2: CI requests an identity token
The job asks the CI platform for an OIDC token. In GitHub Actions this is exposed to the job only if you grant id-token: write. Other CI systems have similar workload identity features.
The token contains claims like repository, ref, workflow, actor, and audience. Your IAM trust policy should match on those claims.
A decoded JWT payload shape usually looks like this:
{
"iss": "https://token.actions.githubusercontent.com",
"aud": "sts.amazonaws.com",
"sub": "repo:acme/payments:ref:refs/heads/main",
"repository": "acme/payments",
"ref": "refs/heads/main",
"workflow": "deploy-prod",
"environment": "production"
}
Step 3: IAM evaluates trust conditions
Your cloud IAM receives the OIDC token and decides whether to issue short-lived deploy credentials.
Good trust conditions usually include all of:
- issuer equals your CI OIDC issuer
- audience equals the cloud STS audience you expect
- repository equals one exact repo
- ref equals
refs/heads/mainor a release tag pattern - optional: workflow name equals
deploy-prod - optional: environment equals
production
This is the control point that prevents a random PR build from deploying.
Step 4: Deploy role gets short-lived credentials
If the trust policy matches, the job receives temporary credentials, often valid for 15–60 minutes. Those credentials are attached to a deploy role whose permissions are much narrower than “admin”. For Kubernetes that might be only patch deployment in one namespace. For VM deploys it might be only write access to one object storage path plus permission to trigger a rollout service.
Step 5: Deploy consumes the built artifact
The deploy step references the immutable artifact built earlier:
kubectl -n payments set image deployment/payments-api \
api=registry.example.com/payments:git-$GITHUB_SHA
kubectl -n payments rollout status deployment/payments-api --timeout=180s
This matters. If deploy rebuilds from source, you have reintroduced build-time code execution into the privileged step.
Step 6: Audit trail shows two identities, not one
Your logs should show:
- build identity publishing
payments:git-abc123 - deploy identity updating
deployment/payments-api
That separation is what lets you answer “who could have changed production?” without also including every job that can run tests.
Common failure mode: trust policy mismatch. The CLI usually returns something like:
$ aws sts assume-role-with-web-identity \
--role-arn arn:aws:iam::123456789012:role/payments-prod-deployer \
--role-session-name gha-12345 \
--web-identity-token file://token.jwt
An error occurred (AccessDenied) when calling the AssumeRoleWithWebIdentity operation:
Not authorized to perform sts:AssumeRoleWithWebIdentity
That usually means one of aud, sub, repository, or ref does not match the trust policy. Decode the token and compare exact strings; branch refs are usually refs/heads/main, not just main.
Another common failure mode is trying to deploy before the image is visible in the registry:
$ kubectl -n payments rollout status deployment/payments-api --timeout=60s
Waiting for deployment "payments-api" rollout to finish: 1 out of 3 new replicas have been updated...
error: deployment "payments-api" exceeded its progress deadline
Then inspect image pull errors:
kubectl -n payments describe pod payments-api-7d6f9c8d7b-abcde
Look for ErrImagePull or ImagePullBackOff; that is a release sequencing problem, not an IAM problem.
When to use it (and when not to)
| Scenario | Recommendation |
|---|---|
| CI runs code from pull requests or unreviewed branches | Use separate build and deploy identities. This is the strongest signal you need it. |
| Production deploys require cloud/API permissions beyond artifact publishing | Use it. Keep deploy rights out of the build job entirely. |
| You already use OIDC/workload identity federation | Use it. The incremental complexity is low and removes long-lived secrets. |
| Small internal app, single protected branch, manual deploy from a trusted laptop | You may not need a separate CI deploy identity yet; a human-operated deploy with MFA may be simpler. |
| CI only builds artifacts; a separate release controller inside the cluster deploys them | You probably don’t need CI-held deploy credentials. Let the controller watch signed artifacts/tags. |
| You rebuild from source during deploy | Stop doing that first. Splitting identities helps, but rebuild-at-deploy keeps privileged code execution in the wrong place. |
| You need per-tenant or per-environment isolation | Use separate deploy identities per environment, and often per service. Don’t share one prod deploy role across everything. |
You probably don’t need this if your pipeline never touches production and only publishes artifacts for another system to release. In that model, the release controller is the deploy identity.
Trade-offs
-
Benefit: smaller blast radius if build is compromised
Cost: more IAM objects, more policy review, and more things to break with a typo in claims matching. -
Benefit: short-lived credentials instead of stored secrets
Cost: dependency on OIDC federation or workload identity support in your CI and cloud; local reproduction of auth failures is less straightforward than pasting a static key. -
Benefit: cleaner audit trail
Cost: you need to preserve artifact provenance and pipeline metadata, or you will know which role deployed but not which build produced the artifact. -
Benefit: environment-specific controls
Cost: policy sprawl. Separate roles for dev/staging/prod are good security, but someone has to maintain them. -
Benefit: easier to add approvals before deploy
Cost: longer lead time and more pipeline states. Teams often feel this as friction before they feel it as safety. -
Benefit: less secret rotation burden
Cost: more trust in your CI issuer and token claims correctness. If the CI issuer is misconfigured, your trust boundary moves rather than disappears.
Lock-in note: the concept is portable; the exact trust-policy syntax is not. Every major cloud has its own way to express OIDC federation and role assumption conditions.
In practice
Example 1: GitHub Actions workflow with separate build and deploy steps
name: build-and-deploy
on:
push:
branches: [main]
permissions:
contents: read
packages: write
id-token: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to registry
run: echo "$REGISTRY_PASSWORD" | docker login registry.example.com -u "$REGISTRY_USER" --password-stdin
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push image
run: |
docker build -t registry.example.com/payments:git-${GITHUB_SHA} .
docker push registry.example.com/payments:git-${GITHUB_SHA}
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Assume deploy role via OIDC
run: ./scripts/assume-deploy-role.sh
- name: Deploy image
run: |
kubectl -n payments set image deployment/payments-api api=registry.example.com/payments:git-${GITHUB_SHA}
kubectl -n payments rollout status deployment/payments-api --timeout=180s
What it does: build can publish the image; deploy is the only job that asks for OIDC-backed deploy credentials. Gotcha: environment: production only helps if your IAM trust policy checks the corresponding claim or your CI enforces approvals on that environment.
Example 2: AWS IAM trust policy for a deploy role assumed from CI OIDC
{
"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",
"token.actions.githubusercontent.com:repository": "acme/payments",
"token.actions.githubusercontent.com:ref": "refs/heads/main"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:acme/payments:*"
}
}
}
]
}
What it does: allows only jobs from one repo on main to assume the role. Gotcha: claim keys and available claims vary by issuer and provider mapping; verify the exact token claims your CI emits before writing conditions.
Example 3: Minimal deploy permissions policy
⚠️ Over-broad deploy permissions are the usual way this pattern fails in practice. If you attach
AdministratorAccessor cluster-admin to the deploy role, you separated identities but not privilege.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"eks:DescribeCluster"
],
"Resource": "arn:aws:eks:us-east-1:123456789012:cluster/prod-cluster"
},
{
"Effect": "Allow",
"Action": [
"ecr:BatchGetImage",
"ecr:GetDownloadUrlForLayer"
],
"Resource": "arn:aws:ecr:us-east-1:123456789012:repository/payments"
}
]
}
What it does: grants only enough cloud-side access to discover the cluster and read the image. Gotcha: Kubernetes authorization is separate; cloud IAM access to the cluster endpoint does not automatically grant namespace-level rights inside the cluster.
For Kubernetes RBAC, bind the deploy identity to a narrow role:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: payments-deployer
namespace: payments
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "patch", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: payments-deployer-binding
namespace: payments
subjects:
- kind: User
name: ci-prod-deployer
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: payments-deployer
What it does: lets the deploy identity update deployments only in the payments namespace. Gotcha: if your auth layer maps cloud roles to Kubernetes groups instead of usernames, bind the correct group, not a guessed user string.
Further reading
- GitHub Actions docs: "OpenID Connect"
- AWS IAM User Guide: "Configuring a role for web identity or OpenID Connect federation"
- Kubernetes docs: "Using RBAC Authorization"
- NIST SP 800-207 "Zero Trust Architecture"
- Supply Chain Security book/article material on "artifact provenance" and "immutable releases"
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI