Skip to content

OIDC: AWS access without stored keys

2 min read · main part only; folded sections are optional

After this you can answer: How does GitHub Actions get into AWS without a stored access key?

flowchart TB
J["GitHub job"] -->|"1. give me a token"| G["GitHub token issuer"]
J -->|"2. exchange signed token<br/>for role credentials"| S["AWS STS"]
S -->|"3. check signature (OIDC provider)<br/>and trust policy: aud, sub"| R["IAM role"]
S -->|"4. credentials, 1-2 h"| J
  1. The OIDC provider. An IAM identity provider for token.actions.githubusercontent.com, with audience sts.amazonaws.com. It tells AWS to accept tokens signed by GitHub.
  2. The job asks for a token. The workflow has id-token: write, which only allows creating the token, not writing to the repo.
  3. The job calls STS (sts:AssumeRoleWithWebIdentity) with the token and a role ARN.
  4. The trust policy checks two claims, with StringEquals: aud must be sts.amazonaws.com, and sub (the subject: who is asking) must match exactly.
  5. STS returns temporary AWS credentials. No stored AWS access key is needed.
RoleUsed bysub must beLasts
minitickets-dev-gh-publishpublish job<prefix>:ref:refs/heads/main1 h
minitickets-dev-gh-deploydeploy job<prefix>:environment:dev2 h

The prefix is repo:maheshnd@63289193/minitickets@1412723955. It is GitHub’s immutable subject format, with the owner’s and the repository’s numeric ids: a deleted and re-created repository with the same name gets new ids and cannot match. A job with environment: dev gets the environment form of sub, never the branch form. Any job in that repository and environment with token permission can match; required reviewers are a separate GitHub setting.

The publish role can log in to ECR and push or pull our three repositories; it cannot deploy them.

Who may assume GitHub publish role: CD publish job (Principal Federated = the GitHub OIDC provider; aud = sts.amazonaws.com; sub = repo:maheshnd@63289193/minitickets@1412723955:ref:refs/heads/main (StringEquals))

What it may do:

OnActionsPolicyWhy only this
ECR repositoriesResource * (the login is registry-wide; AWS accepts only *)ecr:GetAuthorizationTokenidentity EcrLogin publishPolicyDocumentdocker login needs a registry token. It grants nothing by itself.
ECR repositoriesthe 3 minitickets-dev repositories onlyecr:BatchCheckLayerAvailabilityecr:BatchGetImageecr:GetDownloadUrlForLayerecr:InitiateLayerUploadecr:UploadLayerPartecr:CompleteLayerUploadecr:PutImageidentity PushPullMiniticketsRepos publishPolicyDocumentExactly what docker push and the 'does this tag exist?' check need. No delete, no lifecycle changes, no ECS or IAM.
See both roles and the provider on the map →

IAM → Identity providers → token.actions.githubusercontent.com; IAM → Roles → minitickets-dev-gh-publish → Trust relationships.

SymptomCauseWhere to look
“Not authorized to perform sts:AssumeRoleWithWebIdentity”sub mismatch: wrong branch, an environment on the job, or the old name-only formatThe role’s trust policy vs the token
Every CD run fails at “Configure AWS credentials” in a new accountThe OIDC provider does not exist yet (no bootstrap)IAM → Identity providers
Refused although sub is rightThe token was made for another audienceThe aud condition
What happens if the trust policy checks only aud?

Audience identifies AWS, not our repository. IAM rejects a new or updated GitHub trust policy without a valid sub condition. A broad subject can still admit other repositories, so ours uses exact equality. See AWS’s GitHub trust requirements.

What went wrong when the repository moved to immutable subjects?

The token’s sub changed to the form with numeric ids, but the trust policy still expected repo:<owner>/<repo>:…. Every login was refused until the trust policy used the new prefix.

What is the trade-off of the broad deploy role?

Infrastructure needs broad rights, including changing this role’s own inline policy. Scoped statements and the region Deny reduce ordinary mistakes, but are not a hard limit against malicious deployment code. Review the code before approving its GitHub environment job.

Advanced: The deploy role's permissions

Environment subjects do not identify one workflow or branch. Restrict allowed deployment branches in GitHub too; GitHub’s OIDC guide explains the separate protection rules. Their live settings were not verified here. Dependencies installed before the credential step still run within this trusted job; ordering alone does not isolate them from later credentials. The Pulumi access token remains a separate stored secret.

Who may assume GitHub deploy role: CD deploy job (Principal Federated = the GitHub OIDC provider; aud = sts.amazonaws.com; sub = repo:maheshnd@63289193/minitickets@1412723955:environment:dev (StringEquals))

What it may do:

OnActionsPolicyWhy only this
AWS account and region, Monthly cost budget, VPC, Public subnets, Private subnets, Internet gateway, Public route table, Private route table, ALB security group, Web security group, API security group, Database security group, Cache security group, RDS Postgres, ElastiCache Valkey, ECS cluster, API task definition, API ECS service, Web task definition, Web ECS service, ECS Service Connect, Migration task definition, CloudWatch log groups, Saved Logs Insights queries, Application Load Balancer, API target group, Web target group, HTTPS listener, HTTP listener with the origin header, ACM certificate, CloudFront distributionResource *; covers these services throughout the account, including other projectsec2:*elasticloadbalancing:*ecs:*rds:*elasticache:*cloudfront:*acm:*logs:*servicediscovery:*budgets:*kms:DescribeKeyidentity StackServices createCicdPulumi creates, changes and deletes resources before it knows their ids. This broad statement also permits changes outside MiniTickets. The region deny limits it; approval protection depends on GitHub's separately configured environment settings.
RDS master password secret, Redis URL secret, Revalidate secret, Ticket signing secretsecret:minitickets/* and secret:rds!*secretsmanager:*identity OurSecrets createCicdPulumi manages our secrets, and RDS creates its master secret with the caller's permissions. It can also read them: be aware.
API task execution role, API task role, Web task execution role, Worker Lambda execution role, Tickets Lambda execution role, GitHub publish role, GitHub deploy rolerole/minitickets-dev-* onlyiam:CreateRoleiam:DeleteRoleiam:UpdateRoleiam:UpdateRoleDescriptioniam:UpdateAssumeRolePolicyiam:TagRoleiam:UntagRoleiam:GetRolePolicyiam:PutRolePolicyiam:DeleteRolePolicyiam:DetachRolePolicyiam:ListInstanceProfilesForRoleidentity ManageOurRoles createCicdPulumi manages our roles. PutRolePolicy cannot be limited by content: a permissions boundary would be the proper ceiling (not built in dev).
API task execution role, API task role, Web task execution role, Worker Lambda execution role, Tickets Lambda execution role, GitHub publish role, GitHub deploy rolerole/minitickets-dev-*iam:AttachRolePolicyif iam:PolicyARN = arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicyidentity AttachOnlyEcsExecutionPolicy createCicdThe api and web execution roles use this attachment, but the policy permits it on any matching role. The condition prevents attaching AdministratorAccess.
API task execution role, API task role, Web task execution role, Worker Lambda execution role, Tickets Lambda execution role, GitHub publish role, GitHub deploy rolerole/minitickets-dev-*iam:PassRoleif iam:PassedToService in [ecs-tasks.amazonaws.com, lambda.amazonaws.com]identity PassOurRolesToEcsTasksAndLambda createCicdRegistering a task definition, running the migration and creating a Lambda all hand a role to a service. The policy covers any matching role, including the publish and deploy roles.
API task execution role, API task role, Web task execution role, Worker Lambda execution role, Tickets Lambda execution role, GitHub publish role, GitHub deploy roleResource * (read-only)iam:GetRoleiam:ListRolePoliciesiam:ListAttachedRolePoliciesiam:ListRoleTagsidentity ReadIam createCicdPulumi reads the roles it manages; ECS and RDS look up their service-linked roles.
AWS account and regionrole/aws-service-role/*iam:CreateServiceLinkedRoleif iam:AWSServiceName in [ecs, elasticloadbalancing, rds, elasticache].amazonaws.comidentity ServiceLinkedRoles createCicdA new account may not have these AWS-owned roles yet; usually they already exist.
GitHub OIDC identity provideroidc-provider/token.actions.githubusercontent.com in our account onlyiam:GetOpenIDConnectProvideriam:CreateOpenIDConnectProvideriam:DeleteOpenIDConnectProvideriam:TagOpenIDConnectProvideriam:UntagOpenIDConnectProvideriam:ListOpenIDConnectProviderTagsiam:UpdateOpenIDConnectProviderThumbprintiam:AddClientIDToOpenIDConnectProvideriam:RemoveClientIDFromOpenIDConnectProvideridentity GithubOidcProvider createCicdThe OIDC provider is part of the stack, so Pulumi manages it.
ECR repositoriesthe 3 repository ARNsecr:*identity OurEcrRepos createCicdPulumi owns the repositories; cd.yml checks tags and adds deployed-<sha>.
Release parametersonly /minitickets/dev/image-tag and /minitickets/dev/deployed-commitssm:GetParameterssm:GetParametersssm:PutParameterssm:DeleteParameterssm:AddTagsToResourcessm:RemoveTagsFromResourcessm:ListTagsForResourceidentity OurReleaseParameters createCicdCD reads and writes the release values, and Pulumi manages the two parameters and their tags. This statement grants no access to other parameters.
Release parametersparameter/minitickets/dev path ARN onlyssm:GetParametersByPathidentity ReadOurParameterPath createCicdPulumi reads the image tag by its path. That call needs the parent path ARN, not just the two parameter ARNs.
Release parametersResource *; may list parameter metadata outside our path toossm:DescribeParametersidentity ListParameterNames createCicdListing parameter names needs a separate account-wide read permission. It does not return secret parameter values.
Jobs SQS queue, Jobs dead-letter queue, Tickets SQS queue, Processed-jobs table, Tickets table, Worker Lambda, Tickets Lambda, SES email identitiesqueues, tables and functions named minitickets-dev-*; SES identity/* in our account and regionsqs:*dynamodb:*lambda:*ses:*identity JobPipeline createCicdPulumi owns the job pipeline. Event source mappings have separate explicit lifecycle permissions, including the reads used while waiting.
Worker Lambda, Tickets Lambdaarn:aws:lambda:us-east-1:<account>:event-source-mapping:*; all mappings in this account and regionlambda:UpdateEventSourceMappinglambda:DeleteEventSourceMappinglambda:ListTagslambda:TagResourcelambda:UntagResourceidentity LambdaMappingsLifecycle createCicdPulumi updates/deletes mappings and reads/manages their tags. AWS assigns each UUID, so mapping ARNs cannot use our function-name prefix. These writes remain scoped; the deletion waiter's Get has a separate read grant.
Worker Lambda, Tickets LambdaResource *; reads any mapping in the account through the us-east-1 endpointlambda:GetEventSourceMappingidentity LambdaMappingsRead createCicdThe failed compute-off CD log shows Lambda checking GetEventSourceMapping against resource * while Pulumi waits after deletion. An ARN-scoped Get does not cover that request. This specific read permits the waiter to reach its terminal not-found response; it grants no mapping writes.
Worker Lambda, Tickets LambdaResource *; event source mappings are not limited by function name herelambda:CreateEventSourceMappinglambda:ListEventSourceMappingsidentity LambdaMappingsCreateList createCicdA new mapping has no ARN yet, and listing mappings is an account-wide operation. The function role controls which queue an active mapping can read.
Alerts SNS topic, Booking-events SNS topic, CloudWatch alarms, CloudWatch dashboardSNS minitickets-dev-*; alarm:minitickets-dev-*; dashboard/minitickets-devsns:*cloudwatch:*identity Observability createCicdPulumi owns our topics, alarms and dashboard. The SNS pattern also covers the booking-events topic and subscriptions under either topic.
CloudWatch alarmsResource *; may read alarm settings outside our prefix toocloudwatch:DescribeAlarmsidentity ReadAlarms createCicdPulumi lists alarm settings with a separate account-wide read permission. It grants no extra alarm writes.
Ticket assets bucketthe bucket and its objectss3:*identity TicketAssets createCicdPulumi manages the bucket and its settings.
AWS account and region, Monthly cost budget, VPC, Public subnets, Private subnets, Internet gateway, Public route table, Private route table, ALB security group, Web security group, API security group, Database security group, Cache security group, RDS Postgres, ElastiCache Valkey, ECS cluster, API task definition, API ECS service, Web task definition, Web ECS service, ECS Service Connect, Migration task definition, CloudWatch log groups, Saved Logs Insights queries, Application Load Balancer, API target group, Web target group, HTTPS listener, HTTP listener with the origin header, ACM certificate, CloudFront distribution, API task execution role, API task role, Web task execution role, Worker Lambda execution role, Tickets Lambda execution role, GitHub publish role, GitHub deploy role, RDS master password secret, Redis URL secret, Revalidate secret, Ticket signing secret, Processed-jobs table, Tickets table, Ticket assets bucket, ECR repositories, Booking-events SNS topic, Jobs SQS queue, Jobs dead-letter queue, Tickets SQS queue, Worker Lambda, Tickets Lambda, SES email identities, GitHub OIDC identity provider, Release parameters, CloudWatch alarms, Alerts SNS topic, CloudWatch dashboardResource *; all actions on all resourcesDeny *if aws:RequestedRegion != us-east-1identity DenyOtherRegions createCicdAn explicit Deny beats every Allow: requests to other region endpoints are refused. Global services such as IAM and CloudFront use us-east-1 endpoints and pass this condition.

Answer these without scrolling up. If one feels hard, reread that section.

  1. What does the OIDC provider tell AWS?
  2. Which two claims does each trust policy check?
  3. Why does the deploy job get a different sub than the publish job?
  4. What makes the immutable subject format safer?
  5. How long do the publish and deploy credentials last?

Redraw it from memory: job → GitHub token → STS AssumeRoleWithWebIdentity → OIDC provider (signature) + trust policy (aud, sub) → 1-2 h credentials

Try this: on the map, select the GitHub deploy role and find the DenyOtherRegions statement in its Permissions tab

Read this book online: https://book.theversehub.in/