Private vs Public Package Feeds
A practical guide to understanding the differences between private and public package feeds in Azure Artifacts, including visibility models, upstream source behavior, dependency confusion risks, and architectural patterns for feed organization.
Private vs Public Package Feeds
Overview
Azure Artifacts feeds can be configured with different visibility levels, and choosing the wrong one has real security and operational consequences. A feed that is too open leaks internal packages to people who should not see them. A feed that is too locked down blocks developers from doing their jobs. The correct architecture depends on what you are distributing, who consumes it, and how much you trust upstream package registries.
I have seen organizations get burned by both extremes -- teams that published everything to a single organization-wide feed and could not untangle the permissions later, and teams that created per-developer feeds that nobody else could access. This article covers the visibility models available in Azure Artifacts, the real-world implications of each, the dependency confusion attack that makes feed architecture a security concern, and the patterns I recommend for different organizational structures.
Prerequisites
- An Azure DevOps organization with Azure Artifacts enabled
- Organization or project administrator access for creating and configuring feeds
- An understanding of package managers (npm, NuGet, pip, Maven)
- Familiarity with Azure DevOps project structure and permissions model
- Node.js 18+ for the example scripts
Feed Visibility Models in Azure Artifacts
Azure Artifacts offers two primary visibility scopes for feeds, and within each scope you have further control through permissions.
Organization-Scoped Feeds
An organization-scoped feed is visible to every project and every user within your Azure DevOps organization. Any developer with a valid account in the organization can read packages from this feed without additional permission grants.
Create one through the UI by navigating to Organization Settings > Artifacts > Create Feed and leaving the project field empty. Or through the REST API:
// create-org-feed.js
var https = require("https");
var org = "my-organization";
var pat = process.env.AZURE_DEVOPS_PAT;
var auth = Buffer.from(":" + pat).toString("base64");
var feedDefinition = {
name: "shared-packages",
description: "Organization-wide shared packages",
hideDeletedPackageVersions: true,
upstreamEnabled: true
};
var body = JSON.stringify(feedDefinition);
var options = {
hostname: "feeds.dev.azure.com",
path: "/" + org + "/_apis/packaging/feeds?api-version=7.1",
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Basic " + auth,
"Content-Length": Buffer.byteLength(body)
}
};
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() {
if (res.statusCode === 201) {
var feed = JSON.parse(data);
console.log("Organization feed created: " + feed.name);
console.log("Scope: Organization (" + org + ")");
} else {
console.error("Failed (" + res.statusCode + "):", data);
}
});
});
req.on("error", function(err) { console.error("Error:", err.message); });
req.write(body);
req.end();
When to use organization-scoped feeds:
- Shared libraries used by multiple projects (utility packages, SDK wrappers, internal frameworks)
- Upstream source caching that benefits the entire organization
- Company-wide design systems or component libraries
- Standard tooling packages that every team should access
When not to use organization-scoped feeds:
- Team-specific packages that contain sensitive business logic
- Packages tied to specific compliance boundaries (HIPAA, SOC2 projects)
- Experimental packages that are not ready for broad consumption
Project-Scoped Feeds
A project-scoped feed is visible only to members of the Azure DevOps project it belongs to. Users outside the project cannot see or access packages in this feed, even if they are in the same organization.
// create-project-feed.js
var https = require("https");
var org = "my-organization";
var project = "payments-team";
var pat = process.env.AZURE_DEVOPS_PAT;
var auth = Buffer.from(":" + pat).toString("base64");
var feedDefinition = {
name: "payments-packages",
description: "Internal packages for the payments team",
hideDeletedPackageVersions: true,
upstreamEnabled: true
};
var body = JSON.stringify(feedDefinition);
var options = {
hostname: "feeds.dev.azure.com",
path: "/" + org + "/" + project + "/_apis/packaging/feeds?api-version=7.1",
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Basic " + auth,
"Content-Length": Buffer.byteLength(body)
}
};
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() {
if (res.statusCode === 201) {
var feed = JSON.parse(data);
console.log("Project feed created: " + feed.name);
console.log("Scope: Project (" + project + ")");
} else {
console.error("Failed (" + res.statusCode + "):", data);
}
});
});
req.on("error", function(err) { console.error("Error:", err.message); });
req.write(body);
req.end();
When to use project-scoped feeds:
- Team-internal packages with limited audience
- Compliance-bounded projects that must restrict package access
- Prototyping feeds where packages should not leak to the broader organization
- Packages with licensing restrictions that limit distribution
Public Feeds
Azure Artifacts also supports public feeds, tied to public Azure DevOps projects. Public feeds allow unauthenticated read access -- anyone on the internet can install packages from them. Publishing still requires authentication.
Public feeds are appropriate for open-source projects hosted on Azure DevOps. For everything else, you want a private feed. I rarely recommend public feeds for enterprise use because the operational overhead of managing what gets published to a publicly accessible feed is not worth the convenience.
Upstream Sources and Feed Behavior
Upstream sources are where the private vs public distinction gets nuanced. When you configure a public registry (npm, NuGet Gallery, PyPI, Maven Central) as an upstream source on your private feed, your feed becomes a proxy that caches public packages alongside your private ones.
How Upstream Resolution Works
When a developer runs npm install lodash against your Azure Artifacts feed:
- The feed checks its local packages first -- is
lodashpublished directly to this feed? - If not found locally, the feed checks upstream sources in order
- The first upstream source that has the package returns it
- The feed caches the package locally for future requests
This resolution order is critical for security. Local packages always take precedence over upstream packages. If you publish a package named lodash to your feed, it shadows the public lodash from npm. This behavior is both a feature (you can override public packages intentionally) and a risk (name collisions can cause unexpected behavior).
Upstream Source Configuration
// configure-upstream.js
var https = require("https");
var org = "my-organization";
var project = "my-project";
var feedId = "my-packages";
var pat = process.env.AZURE_DEVOPS_PAT;
var auth = Buffer.from(":" + pat).toString("base64");
function getFeed(callback) {
var options = {
hostname: "feeds.dev.azure.com",
path: "/" + org + "/" + project + "/_apis/packaging/feeds/" + feedId + "?api-version=7.1",
method: "GET",
headers: { "Authorization": "Basic " + auth }
};
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() { callback(null, JSON.parse(data)); });
});
req.on("error", callback);
req.end();
}
function updateUpstreams(feed) {
// Configure upstream sources with specific order
feed.upstreamSources = [
{
name: "npmjs",
protocol: "npm",
location: "https://registry.npmjs.org/",
upstreamSourceType: "public"
},
{
name: "NuGet Gallery",
protocol: "nuget",
location: "https://api.nuget.org/v3/index.json",
upstreamSourceType: "public"
},
{
name: "PyPI",
protocol: "pypi",
location: "https://pypi.org/",
upstreamSourceType: "public"
},
{
name: "Maven Central",
protocol: "maven",
location: "https://repo.maven.apache.org/maven2/",
upstreamSourceType: "public"
}
];
var body = JSON.stringify(feed);
var options = {
hostname: "feeds.dev.azure.com",
path: "/" + org + "/" + project + "/_apis/packaging/feeds/" + feedId + "?api-version=7.1",
method: "PATCH",
headers: {
"Content-Type": "application/json",
"Authorization": "Basic " + auth,
"Content-Length": Buffer.byteLength(body)
}
};
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() {
if (res.statusCode === 200) {
console.log("Upstream sources configured:");
feed.upstreamSources.forEach(function(s) {
console.log(" " + s.name + " (" + s.protocol + ")");
});
} else {
console.error("Failed (" + res.statusCode + "):", data);
}
});
});
req.write(body);
req.end();
}
getFeed(function(err, feed) {
if (err) return console.error("Error:", err.message);
updateUpstreams(feed);
});
The Dependency Confusion Attack
In 2021, security researcher Alex Birsan demonstrated a class of attack called dependency confusion that exploits how package managers resolve names across multiple registries. The attack is directly relevant to how you configure your Azure Artifacts feeds.
How the Attack Works
- Your organization has an internal package named
company-auth-utilspublished to a private Azure Artifacts feed - You configure pip/npm/NuGet with both your private feed AND the public registry (PyPI/npmjs/NuGet Gallery) as separate sources
- An attacker publishes a malicious package named
company-auth-utilson the public registry with a higher version number - When a developer runs
pip install company-auth-utils, the package manager sees two sources with the same package name and picks the one with the higher version -- the attacker's malicious package from the public registry
This attack is devastatingly simple and has affected major companies including Apple, Microsoft, and Tesla.
How Azure Artifacts Upstream Sources Prevent This
When you use upstream sources instead of extra-index-url or multiple NuGet sources, Azure Artifacts handles resolution for you:
- Your private feed checks local packages first
- Only if a package is not found locally does it check upstream sources
- Once a package name is claimed locally, upstream versions of that name are blocked
This means if company-auth-utils exists in your feed, an attacker cannot shadow it with a public package of the same name, regardless of version numbers.
Dangerous Configuration (Vulnerable)
# pip.conf -- VULNERABLE to dependency confusion
[global]
index-url = https://pkgs.dev.azure.com/my-org/my-project/_packaging/my-feed/pypi/simple/
extra-index-url = https://pypi.org/simple/
# .npmrc -- VULNERABLE to dependency confusion
@mycompany:registry=https://pkgs.dev.azure.com/my-org/my-project/_packaging/my-feed/npm/registry/
registry=https://registry.npmjs.org/
Safe Configuration (Protected)
# pip.conf -- SAFE: single index, upstream handles PyPI
[global]
index-url = https://pkgs.dev.azure.com/my-org/my-project/_packaging/my-feed/pypi/simple/
# .npmrc -- SAFE: single registry, upstream handles npmjs
registry=https://pkgs.dev.azure.com/my-org/my-project/_packaging/my-feed/npm/registry/
always-auth=true
In both safe configurations, there is a single package source -- your Azure Artifacts feed. The feed has upstream sources (PyPI, npmjs) configured server-side, so public packages resolve through your feed's upstream proxy. Your feed controls the resolution order, not the client.
Package Name Reservation
For additional protection, consider publishing placeholder packages to public registries that match your internal package names. This prevents anyone from registering your internal package names on public registries:
# Publish an empty placeholder to npmjs
npm init -y --scope=@mycompany
npm publish --access restricted
# The package now exists on npmjs but is restricted
# No one else can claim the @mycompany scope
For npm, using a scoped package (@mycompany/package-name) is the strongest protection because npm scopes are globally unique and must be claimed by an organization.
Feed Architecture Patterns
Pattern 1: Single Feed with Upstream Sources (Small Teams)
For organizations with fewer than 50 developers and one or two teams:
[shared-packages]
├── Internal packages (all teams publish here)
├── Upstream: npmjs
├── Upstream: NuGet Gallery
└── Upstream: PyPI
Pros: Simple, single source URL, minimal management. Cons: No isolation between teams, everyone sees everything.
Pattern 2: Hub-and-Spoke (Medium Organizations)
For organizations with 50-500 developers and multiple teams:
[platform-packages] (organization-scoped)
├── Shared libraries, SDKs, frameworks
├── Upstream: npmjs
├── Upstream: NuGet Gallery
└── Upstream: PyPI
[team-a-packages] (project-scoped)
├── Team A internal packages
└── Upstream: platform-packages
[team-b-packages] (project-scoped)
├── Team B internal packages
└── Upstream: platform-packages
Each team has its own project-scoped feed for internal packages. Shared libraries live in an organization-scoped feed. Team feeds use the shared feed as an upstream source, so teams automatically get access to shared packages plus public packages.
Pattern 3: Lifecycle-Based Feeds (Enterprise)
For organizations that need strict release management:
[dev-packages] (project-scoped per team)
├── CI builds, pre-release versions
├── Aggressive retention (7 days, 5 versions)
└── Contributors: build service accounts
[staging-packages] (organization-scoped)
├── Promoted packages under testing
├── Moderate retention (30 days, 20 versions)
└── Contributors: release pipeline service accounts
[release-packages] (organization-scoped)
├── Production-ready packages
├── No automatic retention
├── Upstream: npmjs, NuGet Gallery, PyPI
└── Readers: all developers
Packages flow from dev to staging to release through pipeline promotion. Each feed has different retention policies and access controls.
Comparing Feed Types: Decision Matrix
| Factor | Organization-Scoped | Project-Scoped | Public |
|---|---|---|---|
| Default read access | All org members | Project members only | Anyone |
| Publishing requires | Feed Contributor+ | Feed Contributor+ | Feed Contributor+ |
| Upstream source support | Yes | Yes | Yes |
| Feed views (@Release) | Yes | Yes | Yes |
| Cross-project consumption | Automatic | Requires explicit access | Automatic |
| Dependency confusion risk | Low (with upstream) | Low (with upstream) | N/A |
| Compliance isolation | No | Yes | No |
| Best for | Shared libraries | Team-internal packages | Open source |
Complete Working Example
This example creates a multi-feed architecture for a medium-sized organization, configures upstream sources, sets permissions, and validates the setup:
// setup-feed-architecture.js
var https = require("https");
var org = process.env.AZURE_DEVOPS_ORG || "my-organization";
var pat = process.env.AZURE_DEVOPS_PAT;
if (!pat) {
console.error("Error: AZURE_DEVOPS_PAT is required");
process.exit(1);
}
var auth = Buffer.from(":" + pat).toString("base64");
function apiRequest(method, path, body, callback) {
var options = {
hostname: "feeds.dev.azure.com",
path: path,
method: method,
headers: {
"Content-Type": "application/json",
"Authorization": "Basic " + auth
}
};
if (body) {
var bodyStr = JSON.stringify(body);
options.headers["Content-Length"] = Buffer.byteLength(bodyStr);
}
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() { callback(null, res.statusCode, data); });
});
req.on("error", function(err) { callback(err); });
if (body) req.write(JSON.stringify(body));
req.end();
}
function createFeed(project, name, description, upstreams, callback) {
var feedDef = {
name: name,
description: description,
hideDeletedPackageVersions: true,
upstreamEnabled: upstreams.length > 0,
upstreamSources: upstreams
};
var path = project ?
"/" + org + "/" + project + "/_apis/packaging/feeds?api-version=7.1" :
"/" + org + "/_apis/packaging/feeds?api-version=7.1";
apiRequest("POST", path, feedDef, function(err, status, data) {
if (err) return callback(err);
if (status === 201) {
var feed = JSON.parse(data);
var scope = project ? "Project (" + project + ")" : "Organization";
console.log("[CREATED] " + feed.name + " -- " + scope);
callback(null, feed);
} else if (status === 409) {
console.log("[EXISTS] " + name + " -- already exists, skipping");
callback(null, null);
} else {
callback(new Error("Failed to create " + name + ": " + status));
}
});
}
function setupArchitecture() {
console.log("Setting up feed architecture for " + org);
console.log("=========================================");
console.log("");
var publicUpstreams = [
{ name: "npmjs", protocol: "npm", location: "https://registry.npmjs.org/", upstreamSourceType: "public" },
{ name: "NuGet Gallery", protocol: "nuget", location: "https://api.nuget.org/v3/index.json", upstreamSourceType: "public" },
{ name: "PyPI", protocol: "pypi", location: "https://pypi.org/", upstreamSourceType: "public" },
{ name: "Maven Central", protocol: "maven", location: "https://repo.maven.apache.org/maven2/", upstreamSourceType: "public" }
];
// Step 1: Create organization-scoped shared feed
createFeed(null, "shared-packages", "Organization-wide shared libraries", publicUpstreams, function(err) {
if (err) console.error("Error:", err.message);
// Step 2: Create release feed
createFeed(null, "release-packages", "Production-ready packages", publicUpstreams, function(err) {
if (err) console.error("Error:", err.message);
// Step 3: Create team-specific project feeds
var teams = ["platform", "payments", "data-engineering"];
var completed = 0;
teams.forEach(function(team) {
createFeed(team, team + "-dev", "Development packages for " + team, [], function(err) {
if (err) console.error("Error:", err.message);
completed++;
if (completed === teams.length) {
console.log("");
console.log("Architecture setup complete.");
console.log("");
console.log("Next steps:");
console.log(" 1. Configure team feed upstreams to point at shared-packages");
console.log(" 2. Set retention policies on dev feeds (5 versions, 7 days)");
console.log(" 3. Grant build service Contributor access on each feed");
console.log(" 4. Distribute nuget.config / .npmrc / pip.conf to teams");
}
});
});
});
});
}
setupArchitecture();
node setup-feed-architecture.js
# Output:
# Setting up feed architecture for my-organization
# =========================================
#
# [CREATED] shared-packages -- Organization
# [CREATED] release-packages -- Organization
# [CREATED] platform-dev -- Project (platform)
# [CREATED] payments-dev -- Project (payments)
# [CREATED] data-engineering-dev -- Project (data-engineering)
#
# Architecture setup complete.
#
# Next steps:
# 1. Configure team feed upstreams to point at shared-packages
# 2. Set retention policies on dev feeds (5 versions, 7 days)
# 3. Grant build service Contributor access on each feed
# 4. Distribute nuget.config / .npmrc / pip.conf to teams
Client Configuration for Hub-and-Spoke
Each team gets a .npmrc pointing at their team feed, which resolves up through the shared feed to public registries:
# .npmrc for the payments team
registry=https://pkgs.dev.azure.com/my-organization/payments/_packaging/payments-dev/npm/registry/
always-auth=true
<!-- nuget.config for the platform team -->
<configuration>
<packageSources>
<clear />
<add key="platform-dev"
value="https://pkgs.dev.azure.com/my-organization/platform/_packaging/platform-dev/nuget/v3/index.json" />
</packageSources>
</configuration>
Feed Audit Script
Periodically audit your feed architecture to ensure consistency:
// audit-feeds.js
var https = require("https");
var org = process.env.AZURE_DEVOPS_ORG || "my-organization";
var pat = process.env.AZURE_DEVOPS_PAT;
var auth = Buffer.from(":" + pat).toString("base64");
function apiGet(path, callback) {
var options = {
hostname: "feeds.dev.azure.com",
path: path,
method: "GET",
headers: { "Authorization": "Basic " + auth }
};
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() { callback(null, JSON.parse(data)); });
});
req.on("error", callback);
req.end();
}
apiGet("/" + org + "/_apis/packaging/feeds?api-version=7.1", function(err, result) {
if (err) return console.error("Error:", err.message);
console.log("Feed Architecture Audit");
console.log("=======================");
console.log("");
var warnings = [];
result.value.forEach(function(feed) {
var scope = feed.project ? "Project (" + feed.project.name + ")" : "Organization";
console.log(feed.name + " [" + scope + "]");
// Check upstream configuration
if (!feed.upstreamEnabled) {
console.log(" Upstream: DISABLED");
warnings.push(feed.name + " has no upstream sources -- developers must configure multiple package sources");
} else {
var upstreamCount = (feed.upstreamSources || []).length;
console.log(" Upstream: enabled (" + upstreamCount + " sources)");
}
console.log(" Packages: " + (feed.packageCount || 0));
console.log("");
});
if (warnings.length > 0) {
console.log("WARNINGS:");
warnings.forEach(function(w) {
console.log(" ! " + w);
});
} else {
console.log("No warnings. All feeds configured correctly.");
}
});
Common Issues and Troubleshooting
1. Cross-Project Feed Access Denied
Error:
401 Unauthorized when accessing a feed in a different project
Project-scoped feeds are not accessible outside their project by default. Options: (a) Use an organization-scoped feed instead, (b) Add the requesting user/service account to the other project, or (c) Configure the feed as an upstream source in the consuming project's feed.
2. Dependency Confusion Despite Using Azure Artifacts
Error: A public package version is installed instead of your internal package.
You are using extra-index-url or multiple NuGet sources in your client configuration. Switch to a single feed URL with upstream sources configured server-side. Verify with npm config list or pip config debug that only one index is configured.
3. Upstream Packages Not Resolving
Error:
404 Not Found for a public package when using Azure Artifacts as the sole index
The feed does not have the appropriate upstream source configured, or the upstream source protocol does not match. Verify upstream sources in Feed Settings > Upstream Sources. A NuGet upstream does not help npm packages -- each protocol needs its own upstream.
4. Build Service Cannot Access Organization-Scoped Feed
Error:
403 Forbidden: Build service account cannot read from organization feed
The project build service identity needs explicit permission on organization-scoped feeds. Navigate to the feed's permissions and add [ProjectName] Build Service (org) as a Reader or Contributor.
5. Package Name Collision Between Internal and Upstream
Error: Installing a package returns unexpected content -- the wrong package with the same name.
Your feed has a local package with the same name as an upstream package. Local always wins. Rename the internal package to use a scope or namespace (@mycompany/utils instead of utils). For NuGet, use a company prefix (MyCompany.Utils instead of Utils).
Best Practices
Use upstream sources instead of multiple package source URLs. This is the single most important architectural decision for feed security. A single feed URL with server-side upstream resolution prevents dependency confusion attacks.
Use scoped package names for internal packages. npm scopes (
@company/), NuGet company prefixes (Company.), and Python namespace packages prevent name collisions with public packages.Default to project-scoped feeds, promote to organization-scoped when justified. Start restrictive and broaden access as needed. It is easier to grant access than to revoke it after packages have been widely consumed.
Separate development and release feeds. Dev feeds get aggressive retention and broad write access. Release feeds get conservative retention and restricted write access. This prevents CI noise from polluting your release feed.
Audit feed access quarterly. Teams change, projects get archived, service accounts accumulate. Review who has access to each feed and remove stale permissions.
Never use public feeds for internal packages. Public feeds allow unauthenticated read access. If you are not distributing open-source packages, there is no reason to use a public feed.
Document your feed architecture. Write down which feeds exist, what they are for, who owns them, and what their upstream sources are. New developers need to find the right feed URL without guessing.
Use the
<clear />directive in NuGet configurations. This removes inherited package sources and ensures only your Azure Artifacts feed is used. Without it, developers may have nuget.org configured globally and bypass your feed entirely.Test feed resolution order in a clean environment. Spin up a fresh build agent or container and verify that package resolution works with only your feed URL configured. CI agents accumulate stale credentials and configs that mask problems.
Consider feed-per-compliance-boundary for regulated industries. If your organization has projects under different compliance frameworks (HIPAA, PCI, SOC2), isolate their packages in separate project-scoped feeds to simplify audit scope.