Securing Azure Container Registry: Private Endpoints, RBAC and Image Scanning

TQ
Tran Quang
September 7, 2026 · 12 min read
#Azure#Azure Container Registry#Security#DevOps

A container registry is part of your software supply chain. Anyone who can push to it can change what runs in production, and anyone who can pull from it can read your code and any secrets baked into images. A default registry is reachable from the internet, and the admin user often gets enabled "temporarily" and stays on.

This guide hardens an existing registry step by step: identity and roles, network isolation with private endpoints, vulnerability scanning, image signing and audit logging, ending with a checklist you can apply to every registry. It builds on Introduction to Azure Container Registry; the examples use the resource group rg-apps and the registry crappsdemo.

Start with a security baseline

Before changing anything, record the current state so you know what might break:

bash
az acr show \
  --name crappsdemo \
  --resource-group rg-apps \
  --query "{sku:sku.name, adminUser:adminUserEnabled, publicNetwork:publicNetworkAccess, defaultAction:networkRuleSet.defaultAction, anonymousPull:anonymousPullEnabled, dataEndpoint:dataEndpointEnabled}" \
  --output table

# Who has access today
ACR_ID=$(az acr show --name crappsdemo --query id --output tsv)
az role assignment list --scope "$ACR_ID" --include-inherited --output table

Private endpoints, IP firewall rules and dedicated data endpoints require Premium. On Basic or Standard, everything else still applies, but network isolation needs an upgrade:

bash
az acr update --name crappsdemo --sku Premium

Disable the admin user

The admin user is a single shared credential with full push and pull access to the whole registry. It cannot be scoped, does not identify who used it, and spreads into pipeline variables and laptops. Microsoft Entra ID covers every scenario it used to:

Client Use instead of the admin user
Developers az acr login --name crappsdemo with their own Entra ID account
CI pipelines Workload identity federation (OIDC) or a service principal with AcrPush
AKS Kubelet managed identity with AcrPull
Container Apps, App Service Managed identity with AcrPull
External or legacy clients Repository-scoped tokens (see below)

Check the login events in the diagnostic logs (covered later) first; once no admin logins appear for a reasonable period, disable it:

bash
az acr update --name crappsdemo --admin-enabled false

Azure Policy has built-in definitions that audit or deny registries with the admin account enabled, which is worth assigning at the subscription or management group level so new registries do not drift.

Assign least-privilege RBAC roles

Azure provides registry-specific built-in roles. Use them instead of Owner or Contributor, which also grant control-plane rights such as changing network rules or deleting the registry.

Role Allows Typical assignee
AcrPull Pull images Runtime identities: AKS kubelet, Container Apps, App Service
AcrPush Pull and push images CI build identities
AcrDelete Delete images Cleanup automation, platform team
AcrImageSigner Sign images with Docker Content Trust Legacy signing pipelines only
Reader View the registry resource, not its content Auditors, dashboards
Contributor / Owner Manage the registry resource Platform team, via just-in-time access

Assign roles at the registry scope, not the resource group, so a pull identity for one registry cannot read another:

bash
ACR_ID=$(az acr show --name crappsdemo --query id --output tsv)

# Runtime identity: pull only
az role assignment create \
  --assignee-object-id "<kubelet-or-app-identity-principal-id>" \
  --assignee-principal-type ServicePrincipal \
  --role AcrPull \
  --scope "$ACR_ID"

# CI identity: push
az role assignment create \
  --assignee-object-id "<ci-identity-principal-id>" \
  --assignee-principal-type ServicePrincipal \
  --role AcrPush \
  --scope "$ACR_ID"

Do not give the CI identity Contributor because one step failed; AcrPush covers docker push in most setups, so find the specific failing action instead.

Repository-scoped access

The built-in roles above apply to the whole registry. When a client should only reach specific repositories, for example an external partner pulling one image, use tokens with scope maps:

bash
az acr scope-map create \
  --name orders-pull \
  --registry crappsdemo \
  --repository orders-api content/read metadata/read \
  --description "Read-only access to orders-api"

az acr token create \
  --name partner-orders \
  --registry crappsdemo \
  --scope-map orders-pull \
  --expiration-in-days 90

Tokens are passwords, so give them an expiration and rotate them. Newer registries can also opt into an attribute-based (ABAC) role assignment mode that allows repository-level conditions on Entra ID role assignments; check the current documentation for its role names and tier availability before designing around it.

Choose a network access model

ACR offers three levels of network exposure:

Model Configuration Tier Use when
Public, all networks Default All Development, public images
Public, selected networks --default-action Deny plus IP rules Premium Build agents with fixed egress IPs, transitional setups
Private only Private endpoint, public access disabled Premium Production workloads in virtual networks

IP rules are a quick win when your clients have stable public IPs:

bash
az acr update --name crappsdemo --default-action Deny
az acr network-rule add --name crappsdemo --ip-address 203.0.113.0/24
az acr network-rule list --name crappsdemo --output table

IP rules are brittle for cloud-hosted CI runners whose egress addresses change. Private endpoints are the long-term answer. Virtual network service endpoint rules for ACR have been on a deprecation path, so do not build new designs on them.

Private endpoints and private DNS

A private endpoint gives the registry an IP address inside your virtual network. Clients resolve the registry name to that private IP through a private DNS zone.

bash
ACR_ID=$(az acr show --name crappsdemo --query id --output tsv)

# Private DNS zone for ACR, linked to the virtual network
az network private-dns zone create \
  --resource-group rg-apps \
  --name privatelink.azurecr.io

az network private-dns link vnet create \
  --resource-group rg-apps \
  --zone-name privatelink.azurecr.io \
  --name link-vnet-apps \
  --virtual-network vnet-apps \
  --registration-enabled false

# Private endpoint in a dedicated subnet
az network private-endpoint create \
  --name pe-crappsdemo \
  --resource-group rg-apps \
  --vnet-name vnet-apps \
  --subnet snet-private-endpoints \
  --private-connection-resource-id "$ACR_ID" \
  --group-ids registry \
  --connection-name conn-crappsdemo

# Let Azure manage the A records in the zone
az network private-endpoint dns-zone-group create \
  --resource-group rg-apps \
  --endpoint-name pe-crappsdemo \
  --name default \
  --private-dns-zone privatelink.azurecr.io \
  --zone-name acr

Data endpoints

A registry has two kinds of endpoints. The REST endpoint (crappsdemo.azurecr.io) handles authentication and manifests; layer blobs are served from a data endpoint. With a private endpoint, the zone contains records for both, and the data endpoint record follows the pattern crappsdemo.<region>.data.privatelink.azurecr.io. Each geo-replica adds its own data endpoint record, which is why DNS must be managed by the zone group rather than by hand.

Enabling dedicated data endpoints gives each region a predictable name that is easier to allow in firewalls:

bash
az acr update --name crappsdemo --data-endpoint-enabled true
az acr show-endpoints --name crappsdemo

Test resolution from a machine inside the virtual network before disabling public access. Both names should return private IP addresses:

bash
nslookup crappsdemo.azurecr.io
nslookup crappsdemo.westeurope.data.azurecr.io

The classic failure: login succeeds but docker pull hangs. Usually the REST endpoint resolves privately but the data endpoint does not, because a custom DNS server does not forward to Azure DNS or the zone is not linked to the right virtual network.

Once resolution works everywhere, turn off public access:

bash
az acr update --name crappsdemo --public-network-enabled false

Account for every client: developers need VPN or a jump host, cloud-hosted CI runners need self-hosted agents in the network or ACR Tasks agent pools, and Container Apps environments need a virtual network that reaches the endpoint.

Trusted services, anonymous pull and exports

With public access disabled, some Azure services still need to reach the registry. The trusted services setting lets selected Microsoft services, such as ACR Tasks and Microsoft Defender for Cloud, bypass network rules when they authenticate with a managed identity:

bash
az acr update --name crappsdemo --allow-trusted-services true

Not every Azure service is on the trusted list, so check the current docs.

Anonymous pull (Standard and Premium) lets unauthenticated clients pull any image. Keep it off unless the registry intentionally publishes public content:

bash
az acr update --name crappsdemo --anonymous-pull-enabled false

For sensitive Premium registries, --allow-exports false blocks copying content out to other registries (public access must be disabled first).

Scan images with Microsoft Defender for Containers

Microsoft Defender for Containers, part of Microsoft Defender for Cloud, provides agentless vulnerability assessment for images in ACR. With the plan enabled on the subscription, images are scanned on push and rescanned periodically, and findings appear as Defender for Cloud recommendations listing the affected package and, where available, the fixed version.

bash
az security pricing create --name Containers --tier Standard
az security pricing show --name Containers

To make scanning useful rather than just another dashboard:

  • Rebuild images on a schedule so base image patches arrive without waiting for a feature commit. ACR Tasks base image triggers help here.
  • Use minimal base images (for .NET, the aspnet runtime or chiseled variants) to reduce findings.
  • Block deployment of images with critical findings, and document how exceptions are approved.
  • Scanning does not replace secret scanning of your Dockerfiles and build context.

Scan triggers and supported operating systems change over time, so check the current Defender for Cloud documentation.

Sign images with Notation

Scanning tells you whether an image has known vulnerabilities; signing tells you whether it is the image your pipeline built. ACR stores signatures as OCI artifacts next to the image, following the Notary Project specification, and the notation CLI signs and verifies them. Keys and certificates usually live in Azure Key Vault, accessed through the Notation Azure Key Vault plugin.

At a high level the flow is:

  1. Create or import a signing certificate in Key Vault and give the build identity permission to sign with it.
  2. After pushing, sign the image by digest, not by tag, so the signature cannot be moved to different content.
  3. On the consumer side, configure a trust policy and verify signatures before deployment, for example with Ratify and Azure Policy on AKS.
bash
IMAGE="crappsdemo.azurecr.io/orders-api@sha256:<digest>"

# Sign using a key stored in Azure Key Vault
notation sign --signature-format cose --plugin azure-kv --id "<key-vault-key-id>" "$IMAGE"

# List signatures attached to the image
notation ls "$IMAGE"

The exact plugin configuration depends on whether you use a self-signed or CA-issued certificate, so follow the current Microsoft and Notary Project guides for setup. The older Docker Content Trust mechanism, and the AcrImageSigner role tied to it, has been announced for deprecation in ACR; new pipelines should use Notation.

Send diagnostic logs to Log Analytics

Without logs you cannot answer basic questions such as who pushed an image or where a pull came from. ACR emits two resource log categories: ContainerRegistryLoginEvents (authentication) and ContainerRegistryRepositoryEvents (push, pull, untag, delete).

bash
ACR_ID=$(az acr show --name crappsdemo --query id --output tsv)
LAW_ID=$(az monitor log-analytics workspace show --resource-group rg-apps --workspace-name log-apps --query id --output tsv)

az monitor diagnostic-settings create \
  --name diag-crappsdemo \
  --resource "$ACR_ID" \
  --workspace "$LAW_ID" \
  --logs '[{"category":"ContainerRegistryLoginEvents","enabled":true},{"category":"ContainerRegistryRepositoryEvents","enabled":true}]' \
  --metrics '[{"category":"AllMetrics","enabled":true}]'

Useful starting queries:

text
// Who pushed or deleted what in the last 7 days
ContainerRegistryRepositoryEvents
| where TimeGenerated > ago(7d)
| where OperationName in ("Push", "Delete", "Untag")
| project TimeGenerated, OperationName, Repository, Tag, Identity, CallerIpAddress
| order by TimeGenerated desc

// Recent logins, grouped by identity and source IP
ContainerRegistryLoginEvents
| where TimeGenerated > ago(7d)
| summarize Logins = count() by Identity, CallerIpAddress, ResultDescription
| order by Logins desc

Alert on pushes from identities other than your CI principal, deletes in release repositories, and repeated failed logins. Configuration changes, such as re-enabling the admin user, appear in the Azure Activity Log.

Hardening checklist

Control Setting or command Tier
Admin user disabled az acr update --admin-enabled false All
Entra ID authentication everywhere Managed identities, OIDC for CI All
Least-privilege roles at registry scope AcrPull, AcrPush, AcrDelete All
Repository-scoped tokens with expiry az acr scope-map create, az acr token create Check current docs
Anonymous pull disabled az acr update --anonymous-pull-enabled false Standard, Premium
Public network access disabled az acr update --public-network-enabled false Premium
Private endpoint with private DNS zone privatelink.azurecr.io, zone group Premium
Dedicated data endpoints az acr update --data-endpoint-enabled true Premium
Trusted services only where needed --allow-trusted-services Premium
Export disabled for sensitive registries --allow-exports false Premium
Vulnerability scanning Defender for Containers All
Image signing and verification Notation, trust policy at deploy time All
Diagnostic logs and alerts Login and repository events to Log Analytics All
Policy guardrails Built-in Azure Policy definitions for ACR All

Conclusion

Securing ACR is mostly about removing shortcuts. Replace the admin user with Entra ID identities and registry-scoped roles, put production registries on Premium behind private endpoints with private DNS for both REST and data endpoints, and turn off anonymous pull and public access. Then add the supply chain layer: Defender for Containers for vulnerabilities, Notation for provenance, and diagnostic logs so every push and login is attributable. Verify each change from inside the network before tightening the next, and enforce the result with Azure Policy.

Found this useful?

Share it with someone who might need it.

Related articles All articles