GitHub Actions logs in to ECR Public with OIDC — the fix for 429 Data limit exceeded

GitHub Actions builds hit 429 Data limit exceeded pulling from ECR Public. Logging in with the job's existing OIDC credentials moves pulls to the account quota.

The GitHub Invertocat logo, a cat silhouette cut out of a black circle, beside the GitHub wordmark in black on a white card
On this page

Introduction

The deploy workflow on a project I work on started failing in GitHub Actions on the first line of a Dockerfile, the FROM that pulls the base image:

ERROR: failed to copy: httpReadSeeker: failed open: unexpected status code https://public.ecr.aws/v2/docker/library/python/manifests/sha256:…: 429 Too Many Requests - Server message: toomanyrequests: Data limit exceeded

It failed four times in four days with one green run in between, and the dev environment stopped receiving what was merged to main.

The workflow pulled its base images from Amazon ECR Public without logging in, and ran out of the anonymous quota. Logging in to ECR Public with the job’s existing OIDC credentials fixed it. Builds now pull against the AWS account’s own quota.

Why anonymous pulls from GitHub Actions run out

ECR Public gives anonymous pulls a fixed allowance, and neither the data limit nor the rate limit can be raised. Amazon ECR Public is AWS’s public container registry at public.ecr.aws, which also hosts mirrors of the Docker official images such as docker/library/python. A pull with no auth token counts as anonymous.

AnonymousLogged in to an AWS account
Free data out to the internet500 GB a month, not adjustable5 TB a month
Pull rate1 per second, not adjustable10 per second, adjustable

Some failed runs printed Data limit exceeded and others a bare 429 Too Many Requests, which fits both limits answering with the same status code.

This workflow was not the one spending the 500 GB. A deploy pulls well under 1 GB, and every deploy from late September through mid-October had passed. ECR’s pricing page says anonymous data transfer is limited by source IP. GitHub-hosted runners share outbound addresses with other GitHub users, so their pulls most likely drew on the same allowance.

Once logged in, the quota is counted per AWS account instead of being shared with everyone behind the same IP. An account transfers up to 5 TB a month to the internet for free, and a deploy that pulls under 1 GB stays far inside it.

Logging the deploy job in to ECR Public

One step between the AWS credentials and the build fixes the deploy job. aws-actions/amazon-ecr-login is AWS’s action that turns the job’s AWS credentials into a registry token and runs docker login. With registry-type: public it logs in to public.ecr.aws.

.github/workflows/deploy-dev.yml
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
aws-region: ap-northeast-1
- name: Login to Amazon ECR Public
uses: aws-actions/amazon-ecr-login@v2
with:
registry-type: public
- name: deploy
run: make deploy

The login writes one entry for the public.ecr.aws host into ~/.docker/config.json, so it covers every image there: the lambda/python base images as well as docker/library/python. cdk deploy runs docker build itself, and that build reads the same file. CDK’s own login to the private registry adds a separate entry for a different host and leaves this one alone.

~/.docker/config.json, structure only
{
"auths": {
"public.ecr.aws": { "auth": "…" },
"123456789012.dkr.ecr.ap-northeast-1.amazonaws.com": { "auth": "…" }
}
}

Why the region is us-east-1 and the job stays in Tokyo

Everything else in this project runs in ap-northeast-1, but the ECR Public API exists only in us-east-1 and us-west-2. AWS’s registry authentication page says to always authenticate to us-east-1.

The action’s own source hardcodes us-east-1 for the public registry, so the workflow step needs no region setting:

aws-actions/amazon-ecr-login/index.js
const ecrPublicClient = new ECRPUBLICClient({
// …
// Authenticating to ECR Public auth only works in us-east-1
region: "us-east-1",

The images still come from public.ecr.aws, and only the token request goes to us-east-1. Nothing is created there, and every later step keeps ap-northeast-1.

Logging a GitHub Actions job in to ECR PublicThe GitHub Actions job, running in ap-northeast-1, exchanges its OIDC token with AWS STS for temporary credentials. Those credentials call GetAuthorizationToken on the ECR Public API in us-east-1, which returns a 12-hour token. docker login stores it for public.ecr.aws, and the build then pulls its base images as a logged-in client, against the account quota of 5 TB a month and 10 pulls a second. Without the login step the pull is anonymous, capped at 500 GB a month and 1 pull a second, and fails with 429 Too Many Requests.GitHub Actions jobap-northeast-1AWS STSAssumeRoleWithWebIdentityECR Public APIus-east-1GetAuthorizationTokendocker loginpublic.ecr.awsdocker buildFROM public.ecr.aws/…Account quota5 TB a month10 pulls a secondOIDCtokentemporarycredentials12-hour tokenloggedinWithout the login step: an anonymous pull, capped at 500 GB a month and 1 pull a second429 Too Many Requests · toomanyrequests: Data limit exceeded
The job’s OIDC credentials buy a 12-hour ECR Public token from us-east-1, and the build then pulls from public.ecr.aws as a logged-in client.

The two permissions, and a check against the organization’s SCPs

The role needs ecr-public:GetAuthorizationToken, which issues the registry token, and sts:GetServiceBearerToken, which issues the bearer token ECR Public’s registry API accepts. The action’s README lists exactly these two:

minimum policy from the amazon-ecr-login README
{
"Effect": "Allow",
"Action": [
"ecr-public:GetAuthorizationToken",
"sts:GetServiceBearerToken"
],
"Resource": "*"
}

A service control policy (SCP), which an AWS Organization applies on top of every account’s IAM policies, can still deny a us-east-1 call that IAM allows. An SCP that allows only approved regions is the usual way that happens.

I asked Claude Code to probe for one before touching the workflow, and iam simulate-principal-policy answers it without running a job. The AllowedByOrganizations column is the SCP’s verdict:

Terminal window
IN
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789012:role/YOUR_DEPLOY_ROLE \
--action-names ecr-public:GetAuthorizationToken sts:GetServiceBearerToken \
--context-entries ContextKeyName=aws:RequestedRegion,ContextKeyValues=us-east-1,ContextKeyType=string \
--query 'EvaluationResults[].[EvalActionName,EvalDecision,OrganizationsDecisionDetail.AllowedByOrganizations]' \
--output text
OUT
ecr-public:GetAuthorizationToken allowed True
sts:GetServiceBearerToken allowed True

Which role needs which permission under CDK’s default synthesizer

The GitHub role does not create the stack’s resources. CloudFormation’s execution role does. A stack that names no synthesizer uses CDK’s DefaultStackSynthesizer, and cdk deploy then assumes the bootstrap roles named cdk-hnb659fds-*. CloudFormation creates every resource as cdk-hnb659fds-cfn-exec-role-<account>-<region>.

RoleAssumed byNeeds
The GitHub deploy roleGitHub Actions, through OIDCsts:AssumeRole on cdk-hnb659fds-*, plus the two ECR Public actions
cdk-hnb659fds-cfn-exec-role-…CloudFormationPermission for every resource the stacks create
Which role does what during cdk deployThe GitHub Actions job assumes the GitHub deploy role through OIDC. That role needs sts:AssumeRole to assume the CDK bootstrap roles, cdk-hnb659fds-deploy-role and cdk-hnb659fds-image-publishing-role, plus the two ECR Public actions. The deploy role hands the stacks to CloudFormation, which assumes cdk-hnb659fds-cfn-exec-role and creates the stack resources, such as ECS and Lambda, with its permissions. Resource permissions belong on cfn-exec-role, which the default bootstrap creates with AdministratorAccess. Without sts:AssumeRole on the GitHub role, cdk deploy fails, whatever else that role keeps.GitHub Actions jobdeploy-dev.ymlGitHub deploy rolests:AssumeRole+ 2 ECR Public actionsCDK bootstrap rolescdk-hnb659fds-deploy-role…-image-publishing-roleCloudFormationdeploys the stacksStack resourcesECS, Lambda, …cfn-exec-rolepermission for everyresource the stacks createAdministratorAccessOIDCsts:AssumeRolehands over the stacksassumescreatesWithout sts:AssumeRole on the GitHub role, cdk deploy fails, whatever else the role keeps
Besides the two ECR Public actions, the GitHub role only needs to assume the bootstrap roles. CloudFormation creates the resources with cfn-exec-role, so their permissions go there.

In this account both roles carried AdministratorAccess, so nothing failed. The split matters once someone narrows the GitHub role. Keeping the ECS or Lambda permissions on it while dropping sts:AssumeRole breaks cdk deploy, and the resource permissions belong on cfn-exec-role, which the default bootstrap creates with AdministratorAccess.

Pull request builds get a role that can only fetch the token

So far this has been about the deploy on main, but every pull request also runs an image build. That build pulled the same base images anonymously, so it had already failed 13 times with 429 over ten days.

The deploy role could not be reused for it, because its trust policy accepts only runs on main and it holds AdministratorAccess. Trusting pull requests would hand that access to any branch.

I created a separate role for pull requests whose only permissions are the two ECR Public actions. When a workflow triggered by a pull request assumes this role, it can fetch a token for pulling public images and never gets AdministratorAccess.

The deploy role and the pull-only roleTwo IAM roles assumed through GitHub OIDC. The deploy role trusts only a sub claim ending in :ref:refs/heads/main, holds AdministratorAccess, and is used by the deploy workflow on pushes to main. The pull-only role trusts sub claims ending in :pull_request or :ref:refs/heads/main, allows only ecr-public:GetAuthorizationToken and sts:GetServiceBearerToken, and is used by the image build on every pull request. Dependabot runs skip the login because they receive no secrets.Deploy roleTrusts a sub ending in:ref:refs/heads/mainAllowsAdministratorAccessincludes the two ECR Public actionsUsed bydeploy-dev.yml, on push to mainPull-only roleTrusts a sub ending in:pull_request:ref:refs/heads/mainAllowsecr-public:GetAuthorizationTokensts:GetServiceBearerTokenUsed byci-docker.yml, on every pull requestDependabot runs skip it: they get no secrets
The deploy role keeps trusting main alone. Pull request builds assume a second role that allows only the two token actions.

The trust policy names the pull_request event

GitHub’s OIDC token carries a sub claim naming the repository and what triggered the run, and for a pull_request event it ends in :pull_request. This repository uses GitHub’s immutable subject format, owner@owner-id/repo@repo-id, which repositories created after July 15, 2026 get by default:

trust policy of the pull-only role
{
"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:YOUR_ORG@111111/YOUR_REPO@222222:pull_request",
"repo:YOUR_ORG@111111/YOUR_REPO@222222:ref:refs/heads/main"
]
}
}
}

gh api repos/<owner>/<repo>/actions/oidc/customization/sub prints which format a repository uses. Running iam simulate-principal-policy against the new role confirms it allows the two actions and denies the rest:

Terminal window
IN
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789012:role/YOUR_PULL_ONLY_ROLE \
--action-names ecr-public:GetAuthorizationToken sts:GetServiceBearerToken ecr:GetAuthorizationToken s3:ListAllMyBuckets \
--query 'EvaluationResults[].[EvalActionName,EvalDecision]' --output text
OUT
ecr-public:GetAuthorizationToken allowed
sts:GetServiceBearerToken allowed
ecr:GetAuthorizationToken implicitDeny
s3:ListAllMyBuckets implicitDeny

Dependabot runs skip the ECR Public login and pull anonymously

.github/workflows/ci-docker.yml
permissions:
id-token: write
contents: read
# …
- name: AWS credentials for the ECR Public login
id: aws
if: github.actor != 'dependabot[bot]'
continue-on-error: true
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.AWS_ECR_PUBLIC_ROLE_ARN }}
aws-region: us-east-1
- name: Login to Amazon ECR Public
if: steps.aws.outcome == 'success'
continue-on-error: true
uses: aws-actions/amazon-ecr-login@v2
with:
registry-type: public
  1. The role’s ARN lives in a repository secret, so the workflow file names no account. Any user with write access to an organization repository can create one.
  2. Dependabot runs skip the login. GitHub passes no Actions secrets to workflows Dependabot triggers, so role-to-assume would arrive empty and the step would fail. Dependabot pull requests keep pulling anonymously and can still meet the 429.
  3. The login step tests the credentials step’s outcome. On a Dependabot run the credentials step is skipped and its outcome is skipped, so the login is skipped too and the build pulls anonymously, as it did before.

Summary

Both workflows now log in to ECR Public, so their pulls count against the AWS account’s 5 TB a month instead of an anonymous 500 GB shared with everyone behind the same IP.

The deploy job reuses its existing role with two more actions. Pull request builds assume a separate role that trusts the pull_request subject and can only fetch the pull token.

A workflow that builds from public.ecr.aws gets the same result from one amazon-ecr-login step and the two actions ecr-public:GetAuthorizationToken and sts:GetServiceBearerToken. For pull request builds, give them their own role rather than widening the deploy role’s trust.

References

Share this article