OIDC: AWS access without stored keys
After this you can answer: How does GitHub Actions get into AWS without a stored access key?
Picture it
Section titled “Picture it”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"| JHow it works
Section titled “How it works”- The OIDC provider. An IAM identity provider for
token.actions.githubusercontent.com, with audiencests.amazonaws.com. It tells AWS to accept tokens signed by GitHub. - The job asks for a token. The workflow has
id-token: write, which only allows creating the token, not writing to the repo. - The job calls STS (
sts:AssumeRoleWithWebIdentity) with the token and a role ARN. - The trust policy checks two claims, with
StringEquals:audmust bests.amazonaws.com, andsub(the subject: who is asking) must match exactly. - STS returns temporary AWS credentials. No stored AWS access key is needed.
In MiniTickets
Section titled “In MiniTickets”| Role | Used by | sub must be | Lasts |
|---|---|---|---|
minitickets-dev-gh-publish | publish job | <prefix>:ref:refs/heads/main | 1 h |
minitickets-dev-gh-deploy | deploy job | <prefix>:environment:dev | 2 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
What it may do:
| On | Actions | Policy | Why only this |
|---|---|---|---|
| ECR repositoriesResource * (the login is registry-wide; AWS accepts only *) | ecr:GetAuthorizationToken | identity EcrLogin publishPolicyDocument | docker login needs a registry token. It grants nothing by itself. |
| ECR repositoriesthe 3 minitickets-dev repositories only | ecr:BatchCheckLayerAvailabilityecr:BatchGetImageecr:GetDownloadUrlForLayerecr:InitiateLayerUploadecr:UploadLayerPartecr:CompleteLayerUploadecr:PutImage | identity PushPullMiniticketsRepos publishPolicyDocument | Exactly what docker push and the 'does this tag exist?' check need. No delete, no lifecycle changes, no ECS or IAM. |
See it in AWS
Section titled “See it in AWS”IAM → Identity providers → token.actions.githubusercontent.com; IAM → Roles →
minitickets-dev-gh-publish → Trust relationships.
aws iam get-role --role-name minitickets-dev-gh-publish shows the trust policy.
Break it
Section titled “Break it”| Symptom | Cause | Where to look |
|---|---|---|
| “Not authorized to perform sts:AssumeRoleWithWebIdentity” | sub mismatch: wrong branch, an environment on the job, or the old name-only format | The role’s trust policy vs the token |
| Every CD run fails at “Configure AWS credentials” in a new account | The OIDC provider does not exist yet (no bootstrap) | IAM → Identity providers |
Refused although sub is right | The token was made for another audience | The aud condition |
Say it in an interview
Section titled “Say it in an interview”Follow-ups
Section titled “Follow-ups”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
What it may do:
| On | Actions | Policy | Why 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 projects | ec2:*elasticloadbalancing:*ecs:*rds:*elasticache:*cloudfront:*acm:*logs:*servicediscovery:*budgets:*kms:DescribeKey | identity StackServices createCicd | Pulumi 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 createCicd | Pulumi 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-* only | iam:CreateRoleiam:DeleteRoleiam:UpdateRoleiam:UpdateRoleDescriptioniam:UpdateAssumeRolePolicyiam:TagRoleiam:UntagRoleiam:GetRolePolicyiam:PutRolePolicyiam:DeleteRolePolicyiam:DetachRolePolicyiam:ListInstanceProfilesForRole | identity ManageOurRoles createCicd | Pulumi 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/AmazonECSTaskExecutionRolePolicy | identity AttachOnlyEcsExecutionPolicy createCicd | The 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 createCicd | Registering 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:ListRoleTags | identity ReadIam createCicd | Pulumi 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.com | identity ServiceLinkedRoles createCicd | A 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 only | iam:GetOpenIDConnectProvideriam:CreateOpenIDConnectProvideriam:DeleteOpenIDConnectProvideriam:TagOpenIDConnectProvideriam:UntagOpenIDConnectProvideriam:ListOpenIDConnectProviderTagsiam:UpdateOpenIDConnectProviderThumbprintiam:AddClientIDToOpenIDConnectProvideriam:RemoveClientIDFromOpenIDConnectProvider | identity GithubOidcProvider createCicd | The OIDC provider is part of the stack, so Pulumi manages it. |
| ECR repositoriesthe 3 repository ARNs | ecr:* | identity OurEcrRepos createCicd | Pulumi owns the repositories; cd.yml checks tags and adds deployed-<sha>. |
| Release parametersonly /minitickets/dev/image-tag and /minitickets/dev/deployed-commit | ssm:GetParameterssm:GetParametersssm:PutParameterssm:DeleteParameterssm:AddTagsToResourcessm:RemoveTagsFromResourcessm:ListTagsForResource | identity OurReleaseParameters createCicd | CD 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 only | ssm:GetParametersByPath | identity ReadOurParameterPath createCicd | Pulumi 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 too | ssm:DescribeParameters | identity ListParameterNames createCicd | Listing 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 region | sqs:*dynamodb:*lambda:*ses:* | identity JobPipeline createCicd | Pulumi 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 region | lambda:UpdateEventSourceMappinglambda:DeleteEventSourceMappinglambda:ListTagslambda:TagResourcelambda:UntagResource | identity LambdaMappingsLifecycle createCicd | Pulumi 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 endpoint | lambda:GetEventSourceMapping | identity LambdaMappingsRead createCicd | The 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 here | lambda:CreateEventSourceMappinglambda:ListEventSourceMappings | identity LambdaMappingsCreateList createCicd | A 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-dev | sns:*cloudwatch:* | identity Observability createCicd | Pulumi 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 too | cloudwatch:DescribeAlarms | identity ReadAlarms createCicd | Pulumi lists alarm settings with a separate account-wide read permission. It grants no extra alarm writes. |
| Ticket assets bucketthe bucket and its objects | s3:* | identity TicketAssets createCicd | Pulumi 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 resources | Deny *if aws:RequestedRegion != us-east-1 | identity DenyOtherRegions createCicd | An 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. |
Checkpoint
Section titled “Checkpoint”Answer these without scrolling up. If one feels hard, reread that section.
- What does the OIDC provider tell AWS?
- Which two claims does each trust policy check?
- Why does the deploy job get a different
subthan the publish job? - What makes the immutable subject format safer?
- 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/