AWS Builder Center

Ditch the AWS Keys: Keyless CI/CD with Azure DevOps and OIDC

Static AWS access keys in pipelines are a breach waiting to happen. This guide shows how to replace them with OIDC federation — Azure DevOps requests a short-lived JWT, AWS exchanges it for temporary credentials, and no secret ever touches your pipeline.

AWS access keys stored in Azure DevOps pipeline variables are a credential with no built-in expiry, can be copied anywhere, and grants whatever permissions it was issued with — until explicitly deactivated or deleted by an account administrator. When a key leaks from a pipeline log, a build artifact, or a misconfigured variable group, the damage radius is the full permission set of the IAM user it belongs to.
The correct fix is not better secret hygiene. It is removing the secret entirely.
OpenID Connect (OIDC) federation lets Azure DevOps pipelines prove their identity to AWS without any secret. The pipeline requests a short-lived signed JWT token from Azure DevOps on demand. AWS validates the token's signature against the Azure DevOps OIDC discovery endpoint and exchanges it for temporary STS credentials. The credentials are valid for between 15 minutes and the role's maximum session duration (default 1 hour; configurable up to 12 hours). No long-lived secrets are created. Treat the OIDC JWT and resulting session credentials as secrets for their validity window — mask them immediately, never print them to logs, and monitor CloudTrail for use from unexpected sources.
This guide covers the Entra ID-backed path, which applies when your Azure DevOps organization is connected to Microsoft Entra ID (formerly Azure Active Directory). This is the default for organizations using Microsoft 365 or Azure subscriptions. If your ADO organization is not Entra-backed, the issuer and subject claim formats will differ.

How the trust relationship works

AWS IAM supports external identity providers through the OIDC Identity Provider resource. You register Azure DevOps as a trusted provider by pointing IAM at the Azure DevOps OIDC discovery endpoint. From that point on, an IAM role can be configured to trust JWTs signed by that endpoint — but only for specific issuers, audiences, and subject claims.
When ADO service connections are backed by Microsoft Entra, the OIDC tokens are issued under the Entra issuer, not the legacy Azure DevOps issuer. The JWT that Azure DevOps issues for a pipeline run contains:
ClaimValue
iss (issuer)https://login.microsoftonline.com/<entra-tenant-id>/v2.0
aud (audience)api://AzureADTokenExchange
sub (subject)UUID-based: generated by ADO when the service connection is created
The subject claim is a GUID assigned by ADO at service connection creation time. It is not human-readable and cannot be guessed — it must be copied from the ADO portal after the connection exists. This means the setup sequence matters: create the service connection in ADO first, copy its generated subject identifier, then write the IAM trust policy.
The sub claim is the key to scoping trust precisely. By pinning the exact UUID in the IAM trust policy, you guarantee that only that specific service connection can assume the role.
When a pipeline job runs:
  1. The pipeline script calls System.OidcRequestUri with bearer auth to request an OIDC token on demand.
  2. The pipeline calls sts:AssumeRoleWithWebIdentity with the JWT and the target role ARN.
  3. STS validates the JWT signature against the OIDC discovery endpoint, checks the issuer, audience, and subject against the role's trust policy, and — if everything matches — returns temporary credentials valid for the session duration.
  4. The pipeline uses those credentials for the remainder of the job.
No key is ever created. No key is ever stored.

AWS side setup

Step 1 — Create the IAM OIDC identity provider

The OIDC identity provider tells AWS to trust JWTs from Azure DevOps.
Open the IAM console → Identity providers → Add provider. Select OpenID Connect and enter:
  • Provider URL: https://login.microsoftonline.com/<entra-tenant-id>/v2.0
  • Audience: api://AzureADTokenExchange
Find your Entra tenant ID in the Microsoft Entra admin center → Overview (or Azure Portal → Azure Active Directory → Overview). It is the "Tenant ID" GUID.
For OIDC providers using publicly-trusted CA certificates (including Microsoft's), AWS first validates the TLS certificate using its library of trusted root CAs. Thumbprint validation is used as a fallback only when AWS cannot retrieve the certificate or when TLS v1.3 is required. The CLI requires the thumbprint parameter regardless, so pass a placeholder value:
1
2
3
4
aws iam create-open-id-connect-provider \
--url "https://login.microsoftonline.com/<entra-tenant-id>/v2.0" \
--client-id-list "api://AzureADTokenExchange" \
--thumbprint-list "ffffffffffffffffffffffffffffffffffffffff"
Note the provider ARN — it will look like arn:aws:iam::<account-id>:oidc-provider/login.microsoftonline.com/<entra-tenant-id>/v2.0 and is referenced in the Principal.Federated field of the trust policy.

Step 2 — Create the ADO service connection (get the subject identifier)

Because the Entra-backed subject is a UUID assigned at creation time, you must create the service connection in ADO before writing the IAM trust policy.
In Azure DevOps: Project Settings → Service connections → New service connection → AWS (or generic workload identity).
Note: If your ADO project doesn't have a native AWS connection type, use a generic service connection configured for workload identity federation, or create it via the REST API. The key output is the Subject identifier and Issuer values that ADO generates.
When the connection is saved as a draft, ADO displays two values — copy both:
  • Issuer: https://login.microsoftonline.com/<entra-tenant-id>/v2.0
  • Subject identifier: a UUID string such as sc/<org-id>/<connection-id> or a full Entra-format GUID
These values are also available via the ADO REST API:
1
2
3
curl -s -u :<ado-pat> \
"https://dev.azure.com/<org>/<project>/_apis/serviceendpoint/endpoints?api-version=7.1" \
| jq '.value[] | select(.name=="aws-deploy") | .authorization.parameters'

Step 3 — Create the IAM role

With the subject identifier in hand, write the trust policy. Use StringEquals — not StringLike — for exact subject matching:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::<account-id>:oidc-provider/login.microsoftonline.com/<entra-tenant-id>/v2.0"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"login.microsoftonline.com/<entra-tenant-id>/v2.0:aud": "api://AzureADTokenExchange",
"login.microsoftonline.com/<entra-tenant-id>/v2.0:sub": "<subject-identifier-from-ado>"
}
}
}
]
}
Replace <subject-identifier-from-ado> with the exact value copied from the ADO portal in Step 2. This pins the trust to exactly one service connection — no other connection, even in the same project, can assume this role.
Permission boundaries are required for any role that Terraform uses to manage IAM resources. Terraform deployments commonly need iam:PassRole to configure Lambda execution roles, ECS task roles, and EC2 instance profiles. Without a permission boundary, a pipeline can create a role with AdministratorAccess and pass it to a Lambda — achieving full account access. Attach a boundary policy that caps the maximum permissions:
1
2
3
4
5
aws iam create-role \
--role-name AzureDevOps-Terraform-Deploy \
--assume-role-policy-document file://trust-policy.json \
--permissions-boundary arn:aws:iam::<account-id>:policy/PipelinePermissionBoundary \
--description "Assumed by Azure DevOps pipelines via OIDC for Terraform deployments"
The boundary policy should allow only the specific AWS services Terraform manages — never * on IAM actions.
Once the role is created, return to ADO and complete the service connection setup: paste the role ARN (or the OIDC provider ARN) into the connection's federated credential configuration and save. ADO will verify the trust relationship at save time.

Step 4 — Attach least-privilege policies

Attach only the permissions the pipeline needs. For Terraform using the S3 backend:
1
2
3
aws iam attach-role-policy \
--role-name AzureDevOps-Terraform-Deploy \
--policy-arn arn:aws:iam::<account-id>:policy/TerraformDeployPolicy
Never attach AdministratorAccess. Define a managed policy scoped to the exact actions Terraform needs: S3 (state), DynamoDB (lock table, if used), and the target services. Add permissions incrementally as Terraform plans reveal what is required.

Azure DevOps side setup

Step 1 — Verify OIDC is enabled in your ADO organization

System.OidcRequestUri is available in Azure DevOps Services (cloud-hosted) YAML pipelines when Workload Identity Federation is enabled for your organization. It is not available in Azure DevOps Server (on-premises) or in classic pipelines. Confirm with your ADO administrator that OIDC support is enabled before proceeding.
Once confirmed, the pipeline obtains an OIDC token directly using two built-in pipeline variables — no separate AWS service connection object is created:
  • System.OidcRequestUri — the REST API endpoint Azure DevOps exposes to issue OIDC tokens for the current job.
  • System.AccessToken — the pipeline's bearer token, used to authenticate the OIDC token request.
The pipeline calls System.OidcRequestUri with the desired audience, receives a signed JWT, and passes it to sts:AssumeRoleWithWebIdentity.

Step 2 — Pipeline variable configuration

No additional variable configuration is required beyond what Azure DevOps provides by default. There is no id_token: write declaration needed — that is GitHub Actions syntax and does not exist in Azure Pipelines.
1
2
3
4
5
6
7
jobs:
- job: Terraform
pool:
vmImage: ubuntu-latest
variables:
AWS_ROLE_ARN: arn:aws:iam::<account-id>:role/AzureDevOps-Terraform-Deploy
AWS_REGION: us-east-1

Complete pipeline example

This pipeline runs terraform plan on pull requests and terraform apply on merges to main. The apply stage uses an ADO Environment with a required-approver check to prevent unreviewed infrastructure changes from reaching production automatically.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
trigger:
branches:
include:
- main

pr:
branches:
include:
- main

variables:
AWS_ROLE_ARN: 'arn:aws:iam::<account-id>:role/AzureDevOps-Terraform-Deploy'
AWS_REGION: 'us-east-1'
TF_BACKEND_BUCKET: '<your-terraform-state-bucket>'
TF_BACKEND_KEY: 'my-service/terraform.tfstate'
TF_DIR: '$(Build.SourcesDirectory)/infra'

stages:
- stage: Plan
jobs:
- job: TerraformPlan
displayName: 'Terraform Plan'
pool:
vmImage: ubuntu-latest

steps:
- checkout: self

- script: |
# Request an OIDC token from Azure DevOps — mask it immediately
OIDC_TOKEN=$(curl -sS \
-H "Authorization: Bearer $SYSTEM_ACCESSTOKEN" \
-H "Content-Type: application/json" \
"$SYSTEM_OIDCREQUESTURI?api-version=7.1&audience=api://AzureADTokenExchange" \
| jq -r '.oidcToken')
echo "##vso[task.setvariable variable=OIDC_TOKEN;isSecret=true]$OIDC_TOKEN"

# Exchange the OIDC token for temporary AWS credentials
CREDS=$(aws sts assume-role-with-web-identity \
--role-arn "$AWS_ROLE_ARN" \
--role-session-name "ado-$(Build.BuildId)" \
--web-identity-token "$OIDC_TOKEN" \
--duration-seconds 1800 \
--query 'Credentials' \
--output json)

echo "##vso[task.setvariable variable=AWS_ACCESS_KEY_ID;isSecret=true]$(echo $CREDS | jq -r '.AccessKeyId')"
echo "##vso[task.setvariable variable=AWS_SECRET_ACCESS_KEY;isSecret=true]$(echo $CREDS | jq -r '.SecretAccessKey')"
echo "##vso[task.setvariable variable=AWS_SESSION_TOKEN;isSecret=true]$(echo $CREDS | jq -r '.SessionToken')"
displayName: 'Get OIDC token and assume AWS role'
env:
SYSTEM_ACCESSTOKEN: $(System.AccessToken)
SYSTEM_OIDCREQUESTURI: $(System.OidcRequestUri)
AWS_DEFAULT_REGION: $(AWS_REGION)

- script: |
terraform -version
terraform init \
-backend-config="bucket=$(TF_BACKEND_BUCKET)" \
-backend-config="key=$(TF_BACKEND_KEY)" \
-backend-config="region=$(AWS_REGION)"
displayName: 'Terraform init'
workingDirectory: $(TF_DIR)
env:
AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID)
AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY)
AWS_SESSION_TOKEN: $(AWS_SESSION_TOKEN)
AWS_DEFAULT_REGION: $(AWS_REGION)

- script: terraform plan -out=tfplan
displayName: 'Terraform plan'
workingDirectory: $(TF_DIR)
env:
AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID)
AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY)
AWS_SESSION_TOKEN: $(AWS_SESSION_TOKEN)
AWS_DEFAULT_REGION: $(AWS_REGION)

- publish: $(TF_DIR)/tfplan
artifact: tfplan

- stage: Apply
dependsOn: Plan
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: TerraformApply
displayName: 'Terraform Apply'
environment: 'production' # ← ADO Environment with required approvers configured
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- checkout: self

- script: |
OIDC_TOKEN=$(curl -sS \
-H "Authorization: Bearer $SYSTEM_ACCESSTOKEN" \
-H "Content-Type: application/json" \
"$SYSTEM_OIDCREQUESTURI?api-version=7.1&audience=api://AzureADTokenExchange" \
| jq -r '.oidcToken')
echo "##vso[task.setvariable variable=OIDC_TOKEN;isSecret=true]$OIDC_TOKEN"

CREDS=$(aws sts assume-role-with-web-identity \
--role-arn "$AWS_ROLE_ARN" \
--role-session-name "ado-apply-$(Build.BuildId)" \
--web-identity-token "$OIDC_TOKEN" \
--duration-seconds 1800 \
--query 'Credentials' \
--output json)

echo "##vso[task.setvariable variable=AWS_ACCESS_KEY_ID;isSecret=true]$(echo $CREDS | jq -r '.AccessKeyId')"
echo "##vso[task.setvariable variable=AWS_SECRET_ACCESS_KEY;isSecret=true]$(echo $CREDS | jq -r '.SecretAccessKey')"
echo "##vso[task.setvariable variable=AWS_SESSION_TOKEN;isSecret=true]$(echo $CREDS | jq -r '.SessionToken')"
displayName: 'Get OIDC token and assume AWS role'
env:
SYSTEM_ACCESSTOKEN: $(System.AccessToken)
SYSTEM_OIDCREQUESTURI: $(System.OidcRequestUri)
AWS_DEFAULT_REGION: $(AWS_REGION)

- download: current
artifact: tfplan

- script: |
terraform init \
-backend-config="bucket=$(TF_BACKEND_BUCKET)" \
-backend-config="key=$(TF_BACKEND_KEY)" \
-backend-config="region=$(AWS_REGION)"
terraform apply -auto-approve $(Pipeline.Workspace)/tfplan/tfplan
displayName: 'Terraform apply'
workingDirectory: $(TF_DIR)
env:
AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID)
AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY)
AWS_SESSION_TOKEN: $(AWS_SESSION_TOKEN)
AWS_DEFAULT_REGION: $(AWS_REGION)
Key points in the YAML:
  • jq -r '.oidcToken' — the correct response field name from the ADO OIDC endpoint.
  • api-version=7.1 — the correct API version string per Microsoft's documentation.
  • All three credential components (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN) are marked isSecret=true and masked in logs. The key ID is not safe to log — it encodes the account ID and enables credential validity enumeration.
  • --duration-seconds 1800 — 30 minutes. Size the session to your expected Terraform wall-clock time. Do not default to 3600 if your runs complete in 10 minutes.
  • OIDC JWT is masked immediately after fetching (##vso[task.setvariable ...;isSecret=true]). Never let it appear in logs — it is replayable until its exp claim expires (~10 minutes).
  • The Apply stage uses an ADO deployment job targeting the production Environment. Configure that Environment in ADO with required approvers so no apply runs without a human sign-off.
  • dynamodb_table backend config is omitted — DynamoDB locking is deprecated in Terraform's S3 backend and will be removed in a future version. Use use_lockfile = true in your backend block for S3-native locking instead.
The SYSTEM_OIDCREQUESTURI environment variable is the pipeline variable System.OidcRequestUri mapped into the shell context (Azure Pipelines converts . to _ and upper-cases for env vars).

Security hardening

Pin the trust policy subject with StringEquals, not StringLike. For privileged roles, there is no reason to use StringLike with a literal UUID — StringEquals is semantically correct and makes intent clear. With Entra-backed connections, subjects are UUIDs and StringLike wildcards provide no practical flexibility anyway.
Attach a permission boundary to any role that Terraform uses to manage IAM. Without it, iam:PassRole is an unconstrained escalation path. The boundary policy limits what the role can grant — even if Terraform creates a new IAM role with broad policies, the boundary prevents it from exceeding the pipeline role's own ceiling.
One role per deployment target. A role for dev must not have write access to prod state. Create a separate role per environment, scoped via the sub condition to the corresponding service connection.
Size the session duration to your pipeline. The default is 1 hour; the maximum is 12 hours (set by MaxSessionDuration on the role). Most Terraform runs complete in under 30 minutes — use --duration-seconds 1800. Shorter sessions shrink the window if a token leaks.
Never enable debug logging in production pipelines. system.debug=true prints all environment variables including masked secrets in some contexts. Treat it as a local debugging tool only.
Scope the IAM policy precisely for Terraform state. Terraform needs s3:GetObject, s3:PutObject, and s3:ListBucket on the specific key prefix. When using use_lockfile = true for S3-native locking, s3:DeleteObject is also required (Terraform manages the .tflock file). For DynamoDB locking (legacy, deprecated): dynamodb:GetItem, dynamodb:PutItem, dynamodb:DeleteItem, dynamodb:DescribeTable.
Monitor CloudTrail for AssumeRoleWithWebIdentity calls. Each pipeline run logs the role session name, subject claim, issuer, and source IP. Alert on: calls from IPs outside the known Azure DevOps agent ranges (published by Microsoft), unexpected subject or issuer values, and high call frequency against a single role ARN (replay detection). Enable S3 data events on the state bucket to detect unauthorized reads or writes.
Never reuse the same IAM role for multiple service connections. With Entra-backed connections, each service connection has a unique UUID subject. Adding multiple subjects to the same trust policy means compromising any one connection gives access to all resources the role can reach. Create one role per environment and pin it to one subject UUID.
Nothing long-lived to rotate. There are no static credentials in this setup. Revoking a pipeline's AWS access is a trust policy update — change or delete the subject condition. No key rotation, no secret store cleanup, no credential expiry to track.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article