Safeguard
AI Security

GitHub Container Registry (ghcr.io): A Practical Security Guide

GitHub Container Registry (ghcr.io) is convenient and tightly integrated with Actions, but the defaults can leak images and tokens. Here's how to lock it down properly.

Karan Patel
Platform Engineer
6 min read

GitHub Container Registry (ghcr.io) is a solid place to store container images, but its convenience hides three defaults that regularly leak private images, over-permission tokens, and ship unscanned layers to production. The registry sits inside GitHub alongside your code and Actions, which is exactly why it's easy to misuse — the same GITHUB_TOKEN that builds your image can push it, and the same repository settings that govern your code govern your packages, so a misconfiguration in one surfaces in the other. Used carefully, ghcr.io is a strong, free-for-public option; used on defaults, it's a recurring source of exposure.

This guide covers what GitHub Container Registry is, how to authenticate and publish to it safely, and the specific hardening steps that keep your images private and trustworthy.

What GitHub Container Registry is

GitHub Container Registry is GitHub's OCI-compliant image registry, reachable at ghcr.io. It replaced the older Docker registry that lived at docker.pkg.github.com, and it's part of the broader GitHub Packages service. The key improvement over the old registry is that images are owned at the organization or user level rather than being tied to a single repository, so you can share one image across many repos and manage its visibility independently.

Images are addressed as ghcr.io/OWNER/IMAGE:TAG — for example ghcr.io/acme/api:1.4.2. Public images can be pulled anonymously; private ones require authentication.

Authenticating without leaking a token

To push from your laptop, authenticate with a personal access token, not your password. Create a token scoped to write:packages (and read:packages for pulling), then:

echo "$CR_PAT" | docker login ghcr.io -u USERNAME --password-stdin
docker push ghcr.io/acme/api:1.4.2

Piping the token via --password-stdin keeps it out of your shell history and process list, which a plain -p "$CR_PAT" does not. Never bake a PAT into a Dockerfile or commit it.

Inside GitHub Actions, don't use a personal token at all. Use the automatically provided GITHUB_TOKEN with a narrowly scoped packages: write permission:

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - name: Log in to ghcr.io
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          push: true
          tags: ghcr.io/acme/api:${{ github.sha }}

The permissions block is doing the security work. Without it, the workflow inherits whatever the repository or organization default grants, which is often far more than packages: write. Declaring the minimal set means a compromised step in this job can push a package but can't, say, open pull requests or read secrets it has no business touching. Pin the actions to a version (or better, a commit SHA) rather than a mutable tag, since a hijacked action tag is a direct supply-chain path.

The visibility default that leaks images

Here's the trap that catches people: when a package is first published, its visibility follows specific rules, and a package linked to a public repository — or published without an explicit visibility setting under some org configurations — can end up public. A public image on ghcr.io is pullable by anyone on the internet, anonymously.

That matters because container images routinely contain more than you think: internal package names, build arguments, sometimes an accidentally-copied .env or a cached credential in an intermediate layer. An image you assumed was private, sitting public on ghcr.io, is a disclosure waiting to be found.

Two defenses. First, set organization-level package defaults so new packages are private unless deliberately made public — configure this under the organization's Packages settings rather than trusting per-package defaults. Second, audit what's already public:

# List an org's public container packages via the API
gh api "/orgs/acme/packages?package_type=container&visibility=public"

Anything on that list should be there on purpose. Treat a surprise entry as a potential leak and investigate the image contents, not just the metadata.

Scan images before they reach production

Storing an image in a registry does nothing to tell you whether it's safe. GitHub Container Registry does not block a vulnerable image from being pushed or pulled — that's your pipeline's job. Add a scan step between build and push, and fail the build on high-severity findings:

      - name: Scan image
        uses: aquasecurity/trivy-action@0.28.0
        with:
          image-ref: ghcr.io/acme/api:${{ github.sha }}
          format: sarif
          output: trivy-results.sarif
          severity: CRITICAL,HIGH
          exit-code: '1'

Uploading the SARIF output to GitHub code scanning surfaces the findings next to your code. The scan catches known-vulnerable OS packages and application dependencies baked into the layers — the CVEs in your base image you never chose. For the dependency side specifically, an SCA tool such as Safeguard can track which of those flagged packages your application actually reaches, so you patch the exploitable ones first. Our container image best practices post covers minimizing what ends up in the image in the first place.

Sign and verify for provenance

To prove an image came from your pipeline and wasn't swapped, sign it. cosign supports keyless signing tied to your Actions OIDC identity, so there's no long-lived key to leak:

cosign sign ghcr.io/acme/api:1.4.2
# on the consuming side, verify before deploy
cosign verify ghcr.io/acme/api:1.4.2 \
  --certificate-identity-regexp "https://github.com/acme/.*" \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

Verifying signatures at deploy time closes the gap where an attacker with push access substitutes a malicious image under a legitimate tag. Combined with immutable digests (@sha256:...) instead of mutable tags in your deployment manifests, this makes tag-tampering detectable.

FAQ

What is the GitHub Container Registry URL?

It's ghcr.io, and images are addressed as ghcr.io/OWNER/IMAGE:TAG. It replaced the older docker.pkg.github.com registry and is part of GitHub Packages, with images owned at the org or user level rather than tied to one repository.

How do I authenticate to ghcr.io securely?

From a workstation, use a personal access token scoped to write:packages and pipe it via docker login ghcr.io --password-stdin to keep it out of shell history. In GitHub Actions, use the built-in GITHUB_TOKEN with an explicit minimal permissions: packages: write block instead of a personal token.

Are GitHub Container Registry images private by default?

Not reliably — visibility depends on how the package is created and your org settings, and images can end up public and anonymously pullable. Set org-level package defaults to private and audit existing public packages via the API, because container layers often leak internal details.

Does GitHub Container Registry scan images for vulnerabilities?

No, it stores images without gating on vulnerabilities. Add a scan step (for example, the Trivy action) between build and push, fail the build on critical/high findings, and layer dependency-reachability analysis to prioritize which flagged packages actually matter.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.