# 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.

- Source: https://oharu121.com/blog/github-actions-ecr-public-oidc-429-data-limit-exceeded/
- Published: 2026-10-24T20:16:24+09:00
- Tags: AWS, GitHub Actions, GitHub, Docker

---
## 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:

```text
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.

| | Anonymous | Logged in to an AWS account |
| --- | --- | --- |
| Free data out to the internet | 500 GB a month, not adjustable | 5 TB a month |
| Pull rate | 1 per second, not adjustable | 10 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](https://aws.amazon.com/ecr/pricing/) 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`.

```yaml title=".github/workflows/deploy-dev.yml" ins={5-8}
      - 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.

```json title="~/.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:

```js title="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`.

*Figure — LoginFlow: 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:

```json title="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:

```bash
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
# 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>`.

| Role | Assumed by | Needs |
| --- | --- | --- |
| The GitHub deploy role | GitHub Actions, through OIDC | `sts:AssumeRole` on `cdk-hnb659fds-*`, plus the two ECR Public actions |
| `cdk-hnb659fds-cfn-exec-role-…` | CloudFormation | Permission for every resource the stacks create |

*Figure — RoleChain: 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`.

*Figure — TwoRoles: 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:

```json title="trust policy of the pull-only role" {10-13}
{
  "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:

```bash
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
# ecr-public:GetAuthorizationToken	allowed
# sts:GetServiceBearerToken	allowed
# ecr:GetAuthorizationToken	implicitDeny
# s3:ListAllMyBuckets	implicitDeny
```

### Dependabot runs skip the ECR Public login and pull anonymously

```yaml title=".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

- [Amazon ECR Public service quotas, including the 500 GB anonymous limit and pull rates](https://docs.aws.amazon.com/AmazonECR/latest/public/public-service-quotas.html)
- [Amazon ECR pricing, including the 5 TB a month for authenticated pulls and the per-source-IP limit on anonymous ones](https://aws.amazon.com/ecr/pricing/)
- [Amazon ECR Public endpoints, us-east-1 and us-west-2 only](https://docs.aws.amazon.com/general/latest/gr/ecr-public.html)
- [Registry authentication in Amazon ECR Public, which says to authenticate to us-east-1](https://docs.aws.amazon.com/AmazonECR/latest/public/public-registry-auth.html)
- [aws-actions/amazon-ecr-login](https://github.com/aws-actions/amazon-ecr-login)
- [GitHub's OIDC reference, including the pull_request subject and the immutable subject format](https://docs.github.com/en/actions/reference/security/oidc)
- [Using secrets in GitHub Actions, including who can create them and Dependabot](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets)
