Mateo Hernandez
All notes

September 24, 2026 · 3 min read

CI/CD without AWS keys: GitHub Actions, OIDC and STS

Long-lived AWS keys in GitHub Secrets work, but they don't have to exist. With OIDC, GitHub proves the pipeline's identity, the IAM role's trust policy limits it to one repository and branch, and STS hands out temporary credentials to push images to ECR and deploy through Systems Manager, without SSH.

AWSCI/CDOIDC
GitHub Actions authenticating to AWS STS through OIDC, pushing an image to ECR and deploying to EC2 through Systems Manager

For years, my deploy pipelines started the same way: create an IAM user, generate an access key, paste AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY into GitHub Secrets. It works, and it means a credential that never expires sits in a third-party system so a pipeline can run a few minutes a day.

When I rebuilt Kustto's deploys, I replaced that with OpenID Connect. The pipeline no longer holds an AWS secret at all. It proves who it is, and AWS decides what it may do.

How the exchange works

  • The workflow asks GitHub for an OIDC token: a signed JWT that says which repository, branch and workflow is running.
  • The configure-aws-credentials action sends that token to AWS STS with AssumeRoleWithWebIdentity.
  • STS checks the signature against the identity provider registered in IAM, then evaluates the role's trust policy against the token's claims.
  • If they match, STS returns credentials that expire in at most an hour, scoped to the role's permissions.

The workflow has to ask for the token explicitly. Without id-token: write, GitHub never issues it:

.github/workflows/deploy.ymlyaml
permissions:
  contents: read
  id-token: write

steps:
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::<account>:role/kustto-github-actions-role
      aws-region: us-east-2

The trust policy is the real security boundary

Registering GitHub as an identity provider only says that AWS trusts tokens signed by GitHub. Any repository on GitHub can get one. What stops someone else's pipeline from assuming the role is the trust policy, and specifically its condition on the sub claim.

Trust policy of kustto-github-actions-rolejson
{
  "Effect": "Allow",
  "Principal": {
    "Federated": "arn:aws:iam::<account>:oidc-provider/token.actions.githubusercontent.com"
  },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
    },
    "StringLike": {
      "token.actions.githubusercontent.com:sub": [
        "repo:matehdz150@150376755/kustto-api@*:ref:refs/heads/main",
        "repo:matehdz150@150376755/kustto-web@*:ref:refs/heads/main"
      ]
    }
  }
}

Two things are pinned here. The branch: only main can deploy, so a pull request or a feature branch gets a token that STS rejects. And the owner, by its numeric ID as well as its name, so a different account that took the same name would not match.

What the role can do

Assuming the role is only useful if the role itself is narrow. This one has two inline policies and nothing else:

  • KusttoECRPush: get an ECR login token and push layers and images, only to the kustto-api and kustto-web repositories.
  • KusttoDeployViaSSM: ssm:SendCommand only with the AWS-RunShellScript document and only to the production instance, plus reading the result of that command.

Deploying without SSH

The other half of the change is how the code reaches the server. Instead of an SSH key in GitHub, the instance runs the Systems Manager agent and has an instance role. The pipeline builds the image, pushes it to ECR and asks SSM to run the deploy script on the instance. The script pulls the new image with the instance's own permissions and restarts the containers.

The deploy step, trimmedbash
COMMAND_ID=$(aws ssm send-command \
  --instance-ids "$EC2_INSTANCE_ID" \
  --document-name "AWS-RunShellScript" \
  --comment "Deploy kustto-api $GITHUB_SHA" \
  --parameters '{"commands":["sudo -H -u ec2-user /opt/kustto/deploy-api.sh"]}' \
  --query "Command.CommandId" --output text)

# Poll until it finishes; fail the job on Failed, Cancelled or TimedOut.
aws ssm get-command-invocation --command-id "$COMMAND_ID" \
  --instance-id "$EC2_INSTANCE_ID" --query Status

Because SSM reports the status and output of the script, the GitHub job shows the real result of the deploy. When one deploy of the web failed on the server, the job failed with the script's error, and the next push deployed normally.

What changes

  • No long-lived AWS keys in GitHub, so there is nothing to rotate or leak.
  • No SSH key for deployment, so CI doesn't need port 22 at all.
  • Every deploy is a CloudTrail event tied to a role session and a commit SHA.
  • Least privilege is enforced twice: the trust policy decides who, the permission policies decide what.

The part I like most is the shift in thinking. The pipeline doesn't need to know a secret. It only needs to prove its identity, and the policy decides the rest.