Originally published on kuryzhev.cloud
Every static AWS key sitting in your Jenkins credential store is a standing invitation. It works from anywhere, forever, until someone remembers to rotate it — which is rarely. Jenkins AWS OIDC authentication fixes this by making Jenkins prove who it is on every single run instead of handing it a permanent secret to carry around.
I've migrated three separate Jenkins fleets off static IAM users onto OIDC federation, and the pattern of mistakes is remarkably consistent across teams. This post covers what's actually happening under the hood, where people get it wrong, and the setup that holds up in production.
What this actually does
Jenkins, through an OIDC-capable plugin or a custom step, mints a signed JWT that describes the job: repo, branch, build ID, whatever claims you configure. AWS STS trusts that token — not because "Jenkins can access AWS," but because AWS has registered Jenkins' issuer URL as a trusted OIDC provider and will only accept tokens matching specific claim conditions. The exchange happens through AssumeRoleWithWebIdentity, which trades that signed token for temporary credentials.
This is a fundamentally different trust model than an IAM user's access key. A static key is a bearer credential — no expiry, no scoping to a specific pipeline, no cryptographic proof of *who* is asking. If it leaks, it works everywhere until someone manually revokes it. An OIDC token is short-lived, tied to a specific subject claim, and expires on its own even if nobody notices the leak.
No secret ever crosses the wire in this model. Jenkins doesn't store an AWS credential at rest — it generates a signed assertion at runtime, and AWS validates that assertion against a trust policy before issuing anything. If the assertion doesn't match, there's nothing to steal because nothing was ever stored.
How people use it wrong
The most common failure I see is an overly broad trust policy. Teams condition the trust only on aud, or worse, wildcard the sub claim as repo:my-org/*:*. That means any job, in any repo, on any branch, in the entire org can assume a role meant for production deploys. You've replaced a static key with a slightly more elaborate static key — same blast radius, extra YAML.
Second gotcha: treating the Jenkins issuer URL as permanent. If Jenkins moves behind a new load balancer, gets a new domain, or the box gets rebuilt with a fresh cert, the OIDC discovery URL and JWKS endpoint change. Every trust policy that references the old issuer silently stops matching. Pipelines start failing with opaque AccessDenied errors on AssumeRoleWithWebIdentity, and nobody connects it to the infra change from two weeks ago.
Third: one shared IAM role "to keep things simple." Deploy jobs and read-only lint jobs get identical permissions. A compromised low-trust job — say, a PR build with a tampered pipeline script — now has the same reach as your production deploy. That defeats the entire point of scoping trust in the first place.
Watch out for: Jenkins must be reachable over valid HTTPS for AWS to fetch the JWKS/discovery document. Internal-only Jenkins instances or self-signed certs fail token validation silently — no error at setup time, just mysterious auth failures the first time a pipeline actually tries to assume a role.
The correct approach
Start by registering Jenkins as an IAM OIDC identity provider using its public HTTPS discovery URL. AWS fetches and caches the JWKS thumbprint from that endpoint — this is a one-time registration per AWS account, not per role.
Then build the trust policy around specific claims, not just audience. Pin sub to a concrete job path and aud to the exact identifier your plugin issues. Use StringEquals, never StringLike with wildcards, for anything touching production:
# --- IAM trust policy: scoped to a specific Jenkins job, not wildcarded ---
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/jenkins.example.com/oidc"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
# exact audience your Jenkins OIDC plugin issues
"jenkins.example.com/oidc:aud": "sts.amazonaws.com",
# exact subject — pinned to repo + branch, NOT a wildcard
"jenkins.example.com/oidc:sub": "repo:my-org/infra:ref:refs/heads/main"
}
}
}
]
}
Inside the pipeline, use the Jenkins OIDC plugin (or a manual aws sts assume-role-with-web-identity call) to mint the token and exchange it. Credentials live only in the job's environment for the duration of the stage — they never touch Jenkins' credential store:
# --- Pipeline step: exchange Jenkins-issued JWT for short-lived AWS creds ---
pipeline {
agent any
stages {
stage('Deploy') {
steps {
script {
// Plugin mints a JWT scoped to this job/branch
def idToken = oidcIdToken(audience: 'sts.amazonaws.com')
// Exchange it directly with STS — no static key ever touches Jenkins
sh """
aws sts assume-role-with-web-identity \
--role-arn arn:aws:iam::123456789012:role/jenkins-deploy-main \
--role-session-name jenkins-${env.BUILD_ID} \
--web-identity-token ${idToken} \
--duration-seconds 900 > creds.json
"""
}
}
}
}
}
One role per trust boundary is the rule I don't bend. Plan-only vs apply, staging vs prod, read-only vs deploy — each gets its own role, its own trust policy, its own narrow permission set. A compromised job should never have more reach than its narrowest legitimate need.
Advanced patterns
For multi-account setups, register the OIDC provider once per account that needs to trust Jenkins — don't try to hack a single cross-account provider. Each account's role trust policy still scopes sub to a specific job path, so Jenkins itself never holds account-wide access; it's just trusted differently by each account it touches.
Branch and PR scoping is where OIDC really earns its keep over static keys. Encode the branch or tag into the sub claim so only merges to main can assume the deploy role, while PR builds get a distinctly scoped, usually read-only role. This has to be enforced by the trust policy, not by conditionals inside the pipeline script — a PR can edit the pipeline script itself, but it can't edit an IAM trust policy in another account.
Session tagging closes an audit gap static keys always had. Pass job name, requester, and git SHA as session tags on the assume-role call, and CloudTrail logs show exactly which pipeline execution made which API call instead of an anonymous jenkins-role entry every time.
Layer permission boundaries on top of OIDC-assumed roles as defense-in-depth. If trust policy review lags behind team growth — and it always does — a boundary caps the damage even when a scope gets misconfigured. We cover related guardrail patterns for CI pipelines over on kuryzhev.cloud if you want more on locking down automated deploy paths.
Performance notes
AssumeRoleWithWebIdentity adds one extra network round trip per pipeline run — minting the JWT and exchanging it with STS — typically 100 to 300 milliseconds. That's negligible against most build and deploy times, but worth flagging if you have latency-sensitive smoke-test pipelines that fire dozens of times an hour.
STS calls themselves are free, but poorly scoped roles running across thousands of nightly builds generate real CloudTrail volume. Budget log retention and storage accordingly, especially if you're centralizing logs for compliance.
Token lifetime is typically 15 minutes to an hour by default, configurable up to the role's max session duration. Long-running jobs — multi-hour ETL pipelines, slow Terraform applies across dozens of modules — need to re-assume or chain roles rather than relying on one token for the whole job. "Credentials expired mid-deploy" is a very common failure mode the first time a team hits this wall.
Finally, AWS caches JWKS thumbprints and doesn't refetch instantly on rotation. If you're migrating Jenkins to a new domain or rotating its cert, update the IAM OIDC provider registration in every trusting account before or simultaneously with the DNS cutover — not after. Cutting over DNS first and fixing IAM later is how Jenkins AWS OIDC authentication breaks silently for every pipeline in the fleet at once.
Top comments (0)