# GitHub Actions OIDC to AWS and Azure without long-lived keys

Source: https://startwithidentity.com/recipes/github-actions-oidc-aws-azure/
Last updated: 2026-11-05
License: content by Start with Identity. Cite the source URL.

---

GitHub Actions can authenticate to AWS and Azure with a short-lived OIDC token instead of a stored access key or client secret. The job requests a signed JWT from GitHub, the cloud verifies it against the issuer `https://token.actions.githubusercontent.com`, checks the `aud` and `sub` claims against a trust you configured, and returns credentials valid for that job only. The end state of this recipe: one AWS IAM role and one Entra identity that only a named repository's production deployment can use, and no long-lived cloud keys in GitHub secrets. This is [workload identity federation](https://startwithidentity.com/glossary/workload-identity-federation/) applied to CI, the same pattern [trusted publishing](https://startwithidentity.com/glossary/trusted-publishing/) uses for package registries.

The security of the whole setup rests on one control: the `sub` claim condition. Get it wrong and any workflow in your organization, or anyone's organization, can assume the role.

## Before you start: the sub claim

GitHub documents the [default subject formats](https://docs.github.com/en/actions/reference/security/oidc):

| Trigger | `sub` value |
| --- | --- |
| Job with an environment | `repo:ORG/REPO:environment:NAME` |
| Branch | `repo:ORG/REPO:ref:refs/heads/BRANCH` |
| Tag | `repo:ORG/REPO:ref:refs/tags/TAG` |
| Pull request | `repo:ORG/REPO:pull_request` |

If a job references an environment, the environment format applies and the branch is not in `sub`. Use environments for deployments and add deployment branch rules and required reviewers to them, which [AWS also recommends](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-idp_oidc.html).

One 2026 change matters. For repositories created after July 15, 2026, or that opted in to immutable subject claims, [GitHub's AWS guide](https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws) shows owner and repository IDs in the subject, such as `repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main`. That format is not available on GitHub Enterprise Server. Decode a real token from your repository before writing conditions.

Why this matters: in the [tj-actions/changed-files compromise](https://startwithidentity.com/breaches/incidents/2025-tj-actions-changed-files-ci-secrets/), a poisoned action printed CI secrets into public logs, and in the [GhostAction wave](https://startwithidentity.com/breaches/incidents/2026-ghostaction-github-actions-secrets-wave/) hijacked accounts pushed workflows that dumped Actions secrets. A stored key leaks once and works until rotated. A federated role scoped to `environment:prod` gives a pushed rogue workflow on a feature branch nothing. See [secrets in repositories and CI](https://startwithidentity.com/techniques/secrets-in-repositories-and-ci/).

## 1. Create the GitHub OIDC provider in AWS

```bash
aws iam create-open-id-connect-provider \
    --url https://token.actions.githubusercontent.com \
    --client-id-list sts.amazonaws.com
```

`--thumbprint-list` is [optional](https://docs.aws.amazon.com/cli/latest/reference/iam/create-open-id-connect-provider.html). AWS verifies the JWKS endpoint's TLS certificate with its library of trusted root CAs and uses thumbprints only as a fallback.

**Check:** `aws iam list-open-id-connect-providers` returns `arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com`.
**Common failure:** an error that the provider already exists. Each URL can be registered once per account, so reuse the existing provider.
**Rollback:** `aws iam delete-open-id-connect-provider --open-id-connect-provider-arn <arn>`.

## 2. Create the AWS role with an exact sub condition

`trust.json`:

```json
{
  "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:sub": "repo:example-org/payments-api:environment:prod"
      }
    }
  }]
}
```

```bash
aws iam create-role --role-name gha-payments-prod-deploy \
    --assume-role-policy-document file://trust.json
aws iam attach-role-policy --role-name gha-payments-prod-deploy \
    --policy-arn arn:aws:iam::123456789012:policy/payments-deploy
```

**Check:** `aws iam get-role --role-name gha-payments-prod-deploy --query Role.AssumeRolePolicyDocument` shows `StringEquals` on both keys.
**Common failure:** reaching for `StringLike` with `repo:example-org/*`. IAM [rejects a sub condition that is missing or only a wildcard](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-idp_oidc.html), but it accepts `repo:example-org/*` or `repo:example-org/payments-api:*`. The first trusts every repository in the org; the second trusts every branch and pull request in the repo, including the one a compromised account just pushed. Use `StringLike` only for a deliberate, narrow pattern, and never for production roles.
**Rollback:** `aws iam detach-role-policy`, then `aws iam delete-role --role-name gha-payments-prod-deploy`.

## 3. AWS workflow

[aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials) is on major version v6 and defaults the audience to `sts.amazonaws.com`. Pin third-party actions to a full commit SHA in production; the tag is shown here for readability.

```yaml
name: deploy-prod
on:
  push:
    branches: [main]
permissions:
  id-token: write   # required to request the OIDC JWT
  contents: read
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: prod
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-payments-prod-deploy
          role-session-name: payments-prod-${{ github.run_id }}
          aws-region: us-east-1
      - run: aws sts get-caller-identity
```

**Check:** the last step prints an `assumed-role/gha-payments-prod-deploy/...` ARN.
**Common failure:** `Not authorized to perform sts:AssumeRoleWithWebIdentity`. The token's `sub` does not equal the condition, usually because the job lacks `environment: prod` so `sub` carries the branch ref instead, or the repository uses the immutable ID format.
**Rollback:** revert the workflow file; the role is unused without it.

## 4. Create the Azure federated identity credential

Use an Entra app registration or a user-assigned managed identity, then give it a role assignment on the subscription or resource group. For an app ([Microsoft Learn](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation-create-trust)), `credential.json`:

```json
{
  "name": "payments-prod",
  "issuer": "https://token.actions.githubusercontent.com",
  "subject": "repo:example-org/payments-api:environment:prod",
  "audiences": ["api://AzureADTokenExchange"]
}
```

```bash
az ad app federated-credential create --id <app-object-id> --parameters credential.json
```

For a [user-assigned managed identity](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation-create-trust-user-assigned-managed-identity):

```bash
az identity federated-credential create --name payments-prod \
    --identity-name id-payments-deploy --resource-group rg-payments \
    --issuer https://token.actions.githubusercontent.com \
    --subject repo:example-org/payments-api:environment:prod \
    --audiences api://AzureADTokenExchange
```

**Check:** `az ad app federated-credential list --id <app-object-id>` (or `az identity federated-credential list`) shows the exact subject.
**Common failure:** the exchange fails with no useful error. Microsoft notes a wrong subject is accepted at creation and only fails at token exchange. Wildcards are not supported in any property, and each identity holds at most 20 federated credentials, so plan one credential per environment.
**Rollback:** `az ad app federated-credential delete --id <app-object-id> --federated-credential-id payments-prod`.

## 5. Azure workflow

Store the client, tenant, and subscription IDs as environment secrets (they are identifiers, not credentials). The [Azure/login README](https://github.com/Azure/login) uses v3; [Microsoft Learn](https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure-openid-connect) still shows v2.

```yaml
name: deploy-prod-azure
on:
  push:
    branches: [main]
permissions:
  id-token: write
  contents: read
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: prod
    steps:
      - uses: azure/login@v3
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - run: az account show
```

**Check:** `az account show` prints the expected subscription, signed in as the app or managed identity.
**Common failure:** the login step fails at token exchange. It is almost always a `sub` mismatch, such as a job missing `environment: prod`.
**Rollback:** revert the workflow and delete the federated credential.

## Verify it works

1. Run the workflow from `main` with the `prod` environment. Both clouds return an identity.
2. Push the same workflow to a feature branch without the environment. AWS must return `AccessDenied` and Azure must fail the exchange. If either succeeds, your `sub` condition is too broad.
3. In CloudTrail, find `AssumeRoleWithWebIdentity` events for the role and confirm the subject matches your repository.
4. Delete the old `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and any stored Azure client secret (often `AZURE_CREDENTIALS`), then deactivate the IAM access key and the app client secret. Follow [rotate a leaked secret](https://startwithidentity.com/recipes/rotate-leaked-secret/) if they were ever exposed.

## Related

- [Rotate a leaked secret](https://startwithidentity.com/recipes/rotate-leaked-secret/)
- [Secrets in repositories and CI](https://startwithidentity.com/techniques/secrets-in-repositories-and-ci/)
- [Workload identity federation](https://startwithidentity.com/glossary/workload-identity-federation/) and [trusted publishing](https://startwithidentity.com/glossary/trusted-publishing/)
- [Kubernetes workload identity on EKS and GKE](https://startwithidentity.com/recipes/kubernetes-workload-identity-eks-gke/)
- [GhostAction secrets wave](https://startwithidentity.com/breaches/incidents/2026-ghostaction-github-actions-secrets-wave/)
