<< BACK

OIDC Federation for CI/CD

Deploying to AWS from Bitbucket and Azure DevOps without long-lived access keys — using OIDC identity providers and STS AssumeRoleWithWebIdentity.

DATE:
SEP.15.2025
READ:
12 MIN

The problem with access keys

Every CI/CD pipeline that deploys to AWS needs credentials. The default approach is to create an IAM user, generate an access key pair, and paste it into the pipeline’s secret store. This works until it doesn’t.

Long-lived access keys have three structural problems. First, the CI system can’t rotate them automatically — someone has to generate new keys, update the secret, and deactivate the old ones on a schedule. Second, they leak. A misconfigured build log, a compromised third-party integration, or a developer copying secrets to a local .env file — any of these surfaces the key. Third, IAM access keys are scoped to the user, not the pipeline. Every pipeline that uses the same service account gets the same permissions, regardless of whether it’s deploying to production or running linting.

OIDC federation eliminates access keys entirely. The CI provider becomes a trusted identity provider, and each pipeline job receives short-lived credentials scoped to exactly what it needs.

How OIDC federation works

The flow has four steps:

  1. The CI provider issues a short-lived JWT (JSON Web Token) to the pipeline job at runtime. This token contains claims identifying the repository, branch, and pipeline.
  2. The pipeline calls STS AssumeRoleWithWebIdentity, passing the JWT and the ARN of an IAM role.
  3. AWS validates the token signature against the identity provider’s JWKS endpoint. It then checks the iss, aud, and sub claims against the conditions in the role’s trust policy.
  4. STS returns temporary credentials — an access key, secret key, and session token — valid for up to one hour.

Three claims matter for trust policy conditions:

  • iss — The issuer URL. Identifies which CI provider minted the token.
  • sub — The subject. Encodes the repository, pipeline, or service connection identity.
  • aud — The audience. The intended recipient of the token, used to prevent token reuse across unrelated systems.

The IAM role’s trust policy acts as a gatekeeper. If any claim doesn’t match the conditions, AssumeRoleWithWebIdentity returns AccessDenied.

Bitbucket Pipelines setup

The CDK stack creates two resources: an OIDC identity provider (registered once per AWS account) and an IAM role with a trust policy that restricts assumption to your Bitbucket workspace.

++
bitbucket_provider = iam.OpenIdConnectProvider(
    self, "BitbucketOidc",
    url=f"https://api.bitbucket.org/2.0/workspaces/{WORKSPACE_UUID}/pipelines-config/identity/oidc",
    client_ids=[f"ari:cloud:bitbucket::workspace/{WORKSPACE_UUID}"],
)

deploy_role = iam.Role(
    self, "BitbucketDeployRole",
    role_name="acmecorp-bitbucket-federation",
    assumed_by=iam.FederatedPrincipal(
        bitbucket_provider.open_id_connect_provider_arn,
        conditions={
            "StringEquals": {
                f"{bitbucket_provider.open_id_connect_provider_arn}:aud":
                    f"ari:cloud:bitbucket::workspace/{WORKSPACE_UUID}"
            }
        },
        assume_role_action="sts:AssumeRoleWithWebIdentity",
    ),
)

# Grant specific deployment permissions
deploy_role.add_to_policy(iam.PolicyStatement(
    actions=["s3:PutObject", "s3:DeleteObject", "s3:ListBucket"],
    resources=["arn:aws:s3:::acmecorp-frontend-*"],
))
deploy_role.add_to_policy(iam.PolicyStatement(
    actions=["cloudfront:CreateInvalidation"],
    resources=["*"],
))
deploy_role.add_to_policy(iam.PolicyStatement(
    actions=["ecr:GetAuthorizationToken", "ecr:BatchCheckLayerAvailability",
             "ecr:PutImage", "ecr:InitiateLayerUpload", "ecr:UploadLayerPart",
             "ecr:CompleteLayerUpload"],
    resources=["*"],
))
++

To restrict to a specific repository, add a sub condition using the repository UUID:

++
conditions={
    "StringEquals": {
        f"{bitbucket_provider.open_id_connect_provider_arn}:aud":
            f"ari:cloud:bitbucket::workspace/{WORKSPACE_UUID}",
    },
    "StringLike": {
        f"{bitbucket_provider.open_id_connect_provider_arn}:sub":
            f"{REPO_UUID}:*",
    },
}
++

Azure DevOps setup

Azure DevOps follows the same pattern with a different issuer URL and audience format. The issuer is scoped to your Azure DevOps organization GUID, and the audience is always api://AzureADTokenExchange.

++
azure_provider = iam.OpenIdConnectProvider(
    self, "AzureDevOpsOidc",
    url=f"https://vstoken.dev.azure.com/{ORG_GUID}",
    client_ids=["api://AzureADTokenExchange"],
)
++

The subject claim format for Azure DevOps service connections is:

++
sc:///OrgName/ProjectName/ServiceConnectionName
++

This means your trust policy can scope access down to a specific service connection within a specific project — a level of granularity that IAM access keys can’t provide.

Pipeline configuration

On the Bitbucket side, the pipeline step needs the oidc: true flag to request a token. The AWS CLI and SDKs natively support web identity token files, so no custom authentication code is needed.

++
export AWS_WEB_IDENTITY_TOKEN_FILE=$(mktemp)
echo $BITBUCKET_STEP_OIDC_TOKEN > $AWS_WEB_IDENTITY_TOKEN_FILE
export AWS_ROLE_ARN="arn:aws:iam::123456789012:role/acmecorp-bitbucket-federation"
aws s3 sync build/ s3://acmecorp-frontend-qa/
++

The AWS CLI detects AWS_WEB_IDENTITY_TOKEN_FILE and AWS_ROLE_ARN automatically. It calls AssumeRoleWithWebIdentity behind the scenes, caches the temporary credentials, and refreshes them if the step runs long enough for them to expire.

No access keys are stored anywhere — not in Bitbucket secrets, not in environment variables, not in the pipeline configuration.

Provider comparison

+----------------+--------------------+--------------------+--------------------+
| Provider       | Issuer             | Subject format     | Audience           |
+----------------+--------------------+--------------------+--------------------+
| Bitbucket      | api.bitbucket.org/ | {REPO_UUID}:*      | ari:cloud:bitbucke |
|                | ...                |                    | t::workspace/{UUID |
|                |                    |                    | }                  |
+----------------+--------------------+--------------------+--------------------+
| Azure DevOps   | vstoken.dev.azure. | sc:///Org/Proj/Con | api://AzureADToken |
|                | com/{GUID}         | n                  | Exchange           |
+----------------+--------------------+--------------------+--------------------+
| GitHub Actions | token.actions.gith | repo:org/repo:ref: | sts.amazonaws.com  |
|                | ubusercontent.com  | ...                |                    |
+----------------+--------------------+--------------------+--------------------+

Each provider encodes identity differently in the sub claim, but the federation mechanics are identical. Once you understand the pattern for one provider, adding another is a matter of looking up the issuer URL and subject format.

Security benefits

OIDC federation removes an entire class of credential management problems:

  • No keys to rotate. There are no long-lived secrets. Temporary credentials expire automatically after the pipeline step completes.
  • Automatic credential expiry. STS credentials have a maximum lifetime of one hour. A leaked session token is useless after expiry — no need for incident response to revoke it.
  • Per-pipeline scoping. Trust policy conditions on sub restrict which repositories or service connections can assume each role. A frontend deployment pipeline can’t assume the database migration role.
  • Full audit trail. Every AssumeRoleWithWebIdentity call appears in CloudTrail with the full token claims. You can trace any AWS API call back to the specific pipeline run, repository, and branch that initiated it.

Credentials should be ephemeral by default. If a secret doesn’t expire on its own, you’ve accepted an operational liability that will eventually come due.