Back to glossary

Principle of Least Privilege (PoLP)

The principle of least privilege gives every user, process, token, and AI agent only the access its task needs, so a compromise can do little damage.

Also known as Least Privilege Principle, Principle of Minimal Privilege

The principle of least privilege (PoLP, often shortened to least privilege) is the rule that every user, process, credential, and AI agent gets only the permissions its current task requires, for only as long as it needs them. Least privilege does not prevent compromise. It caps what an attacker inherits when code or a credential is hijacked.

That cap is the whole game in the open source supply chain. Attackers rarely need to escalate anything: they run inside your CI job, your npm install, or your coding agent, and they inherit whatever that process already holds. In practice that means a write-scoped GITHUB_TOKEN, a long-lived npm publish token, or a shell that can read ~/.ssh and ~/.npmrc. Our position is that least privilege has to be applied to non-human principals first: CI jobs, tokens, dependencies, and agents. That is where the excess privilege sits, and it is what attackers have been harvesting since 2025. The uncomfortable part is that mainstream runtimes cannot enforce least privilege at the granularity that matters most. Inside a single process, every dependency holds the same authority as your own code.

What is the principle of least privilege?

#

The principle of least privilege is a design rule for limiting damage, first stated by Jerome Saltzer and Michael Schroeder in their 1975 paper The Protection of Information in Computer Systems: "Every program and every user of the system should operate using the least set of privileges necessary to complete the job." The paper's argument is about damage and audit: fewer privileged programs means fewer places a misuse of privilege can occur, and fewer programs to examine when one does.

A privilege grant has three dimensions, and least privilege constrains all three:

  • Scope: which resources and actions. Read one repository, not write every repository in the organization.
  • Duration: how long the grant lives. A token minted for one publish and expired minutes later, not a secret that lives for a year in a CI settings page.
  • Holder: which principal carries it. The publish step, not every step in the job that happens to run before it.

Most real failures are on duration and holder, not scope. A narrowly scoped npm token is still dangerous if it never expires and sits in an environment variable that every postinstall script in the dependency tree can read.

Least privilege is a goal, and the access control vocabulary describes the machinery that implements it. Visitors looking for those terms will find them here:

  • Authorization decides what an authenticated principal may do. Least privilege is a rule about how generous authorization should be.
  • Access control is the enforcement layer. An access control list (ACL) attaches allowed principals to each resource.
  • Role-based access control (RBAC) grants permissions to roles and roles to principals. Attribute-based access control (ABAC) evaluates attributes such as branch, environment, or time at request time. GitHub's environment: protection rules and OIDC claim conditions are ABAC in practice.
  • Identity and access management (IAM) is the system that issues identities and binds them to permissions. Privileged access management (PAM) adds just-in-time elevation and auditing for the most powerful accounts.
  • Privilege escalation is the attack least privilege tries to make pointless: turning a low-privilege foothold into a high-privilege one. In supply chain attacks the escalation step is usually a credential the foothold could already read.

Every grant you remove shrinks the part of the attack surface an intruder can reach after the first foothold. Least privilege does nothing to stop the foothold itself.

Why least privilege stops at the process boundary

#

Operating systems enforce privilege per user and per process. Package managers do not create new processes for each library you import. A dependency loaded with require() or import runs in your process, with your process's file handles, environment variables, network access, and ability to spawn shells. Security engineers call this ambient authority: code gets power from where it runs, not from what it was granted.

Deno, which was built around runtime permissions, states the limit plainly in its security documentation: "It is not possible for different modules to have different privilege levels within the same thread." Every transitive dependency in that thread holds the same privilege as your entry point.

Node.js shares the constraint, and its permission model is narrower than Deno's. We ran the script below against a sandboxed home directory containing a fake ~/.npmrc token, first without and then with --permission:

JavaScript
const fs = require('fs');
const home = process.env.HOME;
try { fs.readFileSync(home + '/.npmrc', 'utf8'); console.log('read ~/.npmrc: ok'); }
catch (e) { console.log('read ~/.npmrc:', e.code, e.permission, e.resource); }
try { require('child_process').execSync('id'); console.log('spawn: ok'); }
catch (e) { console.log('spawn:', e.code, e.permission); }
console.log('NPM_TOKEN visible:', process.env.NPM_TOKEN !== undefined);
Plain text
$ node --version
v22.23.4
$ NPM_TOKEN=npm_fake node app.js
read ~/.npmrc: ok
spawn: ok
NPM_TOKEN visible: true
$ NPM_TOKEN=npm_fake node --permission --allow-fs-read="$PWD/app.js" app.js
read ~/.npmrc: ERR_ACCESS_DENIED FileSystemRead /home/claude/lp/home/.npmrc
spawn: ERR_ACCESS_DENIED ChildProcess
NPM_TOKEN visible: true

The permission model blocked the file read and the subprocess. The token in the environment stayed readable, because Node's permission flags cover the filesystem, child processes, workers, native addons, WASI, FFI, and network, with no scope for environment variables. Node's permission model cannot hide an environment variable from a dependency. Network gating via --allow-net only arrived in Node.js v25.0.0, so the v22 LTS line we tested cannot restrict outbound connections at all. The Node.js documentation is explicit that this is a seat belt, not a cage: "It does not provide security guarantees in the presence of malicious code."

We think that framing is correct, and it decides where least privilege can actually be enforced against a hostile dependency.

BoundaryWhat it can scopeHolds against malicious code?Cost
Node.js permission model (--permission)Whole process: filesystem paths, child processes, workers, addons, network (v25+). No env var scope.No. Node documents it as a seat belt for trusted code.Low. Native addons need --allow-addons.
Deno permissions (--allow-*)Whole thread: specific hosts, paths, env var names, binaries.Partly. Subprocesses granted by --allow-run run outside the sandbox.Low to medium. Every flag must be declared.
Separate OS process, container, or VMWhatever you mount, route, and inject.Yes. The kernel or hypervisor enforces it.High. You build the plumbing.
Separate CI jobSecrets, token scopes, and id-token per job.Yes, for credentials. Jobs run on separate runners.Medium. Outputs cross as artifacts.
Scoped, short-lived credentialActions on one registry, repo, or package, for minutes.Limits damage after theft. Does not stop theft.Low, once the identity plumbing exists.

Verdict: use runtime permission flags to catch your own mistakes. Use process, job, and credential boundaries for anything that must hold when a dependency turns hostile. A runtime that cannot give lodash less authority than your payment code is not a place to enforce least privilege against a supply chain attack.

How do you enforce least privilege for CI tokens?

#

You enforce least privilege for CI tokens by giving each job a GITHUB_TOKEN with only the permissions that job uses, keeping long-lived publish secrets out of the pipeline, and giving no credential at all to jobs that execute dependency or contributor code. On GitHub Actions the platform default only partly helps. GitHub switched the default GITHUB_TOKEN to read-only on February 2, 2023, but only for new enterprises, organizations, and repositories. The read-only default never reached organizations created before that date, and they still issue a read/write token unless someone changed the setting.

The default matters less than the structure. As our guide to hardening GitHub Actions against outside threats puts it, any user with write access to a repository can read every secret in it by pushing a workflow that uses them. So the question is never whether a job can reach a credential. It is how much a compromised job can do with what it reaches. That is GitHub Actions security reduced to one sentence.

How the Nx compromise chained four excess privileges

#

The Nx s1ngularity compromise in August 2025 is the clearest case study we know of, because every hop used a privilege that something held but did not need. We reconstructed the chain from Nx's GitHub advisory, Nx's investigation, and our own analysis of the payload.

The four hops in the figure, with the privilege each one exploited and the control that would have cut it:

HopDate (2025)Privilege heldPrivilege neededControl that cuts it
1. PR title injected into a pull_request_target workflowAug 21 to Aug 24GITHUB_TOKEN with read/write on nrwl/nxRead-only, to validate commit titlesPlain pull_request, permissions: {}, attacker input passed through env:
2. Stolen token triggers publish.ymlAug 24Long-lived npm publish token reachable from any run of that workflowA credential minted only for an approved releaseTrusted publishing from a gated job that runs no project code
3. Eight malicious nx versions run postinstall on every installing machineAug 26, 22:32 UTC to about 02:44 UTC Aug 27The developer's full user account: gh auth token, ~/.npmrc, env vars, SSH keysNothing. A build tool does not need to run code at install time.Install scripts off by default; install in a credential-free environment
4. Payload drives local AI CLIs and the victim's gh sessionAug 26 to 27Agents and a GitHub CLI with standing access to the home directory and the accountProject-scoped access, granted per taskAgent deny rules plus an OS sandbox; short-lived, narrowly scoped GitHub tokens

The outcome: npm pulled the versions after about four hours, and by then public data showed attacker-created s1ngularity-repository repos, full of stolen credentials, in more than 1,000 victim accounts. No hop required a vulnerability in npm, GitHub, or the AI tools. Each one was a grant that was broader, longer-lived, or held by more code than its job required.

Nx's fix after the incident was a least-privilege fix. All Nx packages moved to trusted publishing, so no static npm token exists to steal.

A release workflow with nothing to steal by default

#

The workflow below applies the same lessons. The top-level permissions: {} grants nothing. The build job, which executes dependency code, gets read access to the repository and no publishing credential. The publish job runs no project code, sits behind a protected environment, and holds the only id-token: write grant.

YAML
name: release
on:
  push:
    tags: ['v*']

permissions: {}               # no job inherits anything

jobs:
  build:                      # runs dependency code: give it nothing to steal
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@<sha>        # pin every action to a full commit SHA
        with:
          persist-credentials: false
      - run: npm ci --ignore-scripts
      - run: npm run build && npm pack
      - uses: actions/upload-artifact@<sha>
        with:
          name: tarball
          path: '*.tgz'

  publish:                    # runs no project code
    needs: build
    runs-on: ubuntu-latest
    environment: npm-publish  # protected: required reviewers
    permissions:
      id-token: write         # mint a short-lived OIDC token for trusted publishing
    steps:
      - uses: actions/setup-node@<sha>
        with:
          node-version: 24
      - uses: actions/download-artifact@<sha>
        with:
          name: tarball
      - run: npm publish ./*.tgz

The split is the important part, not the YAML. id-token: write is granted per job, so a compromised build step in the same job as the publish could mint its own publish token. That is not hypothetical. In the May 2026 TanStack compromise, attackers extracted an OIDC token from runner memory and published 84 malicious versions across 42 packages through the project's legitimate trusted-publisher binding, as our coverage of npm's token reset details. Trusted publishing removed the long-lived secret, which shortened the duration of the grant. It did not change the holder, and the holder was the job the attacker controlled.

The cost is real. Artifacts become the trust boundary, so the publish job must treat the tarball as untrusted data and never execute it. Reviewers on the protected environment add latency to every release. And none of this helps if the attacker already holds a maintainer's credentials, which we return to below.

Install time: the privilege nobody granted on purpose

#

For most of npm's history, npm install executed preinstall, install, and postinstall scripts from every package in the tree, with the full privileges of whoever ran it. Nobody decided that a date-formatting library should be able to read ~/.aws/credentials during installation. It was simply the default, and install scripts became the most reliable execution point in the ecosystem. Shai-Hulud used exactly that path in September 2025: harvest tokens at install, then republish the victim's own packages with the payload inside.

That default was a design mistake, and it has been corrected. npm v12, released in July 2026, ships with allowScripts off. Dependency lifecycle scripts, the implicit node-gyp rebuild, git dependencies, and remote tarball dependencies all require explicit approval. pnpm 10 blocked dependency lifecycle scripts by default in January 2025. Least privilege at install time now means an allowlist of the few packages that need a build step, ideally with strict-allow-scripts in CI so a new script fails the build instead of printing a warning.

Blocking scripts removes install time as a free execution point. It does not change what a package can do once your application imports it, which brings back the ambient authority problem. Because no mainstream runtime can give one dependency less privilege than another, the practical substitute is to know what each dependency actually does. Socket's static analysis flags the capabilities a package uses, including network access, filesystem access, shell access, and environment variable access. An update to a string utility that suddenly reads environment variables and opens a socket is asking for privilege its job never required. That is a least-privilege violation you can catch before install, even though no runtime would stop it afterward.

How do you enforce least privilege for AI agents?

#

You enforce least privilege for AI agents by treating the agent as a separate principal: deny it the credentials it does not need, confine its shell and file access with an operating-system sandbox, and keep the policy file outside the agent's reach. An agent's effective privilege is the union of your user account, every token in its environment, every tool and MCP server it can call, and its permission mode. Run with a bypass flag, that union is everything you can do.

Attackers already use that union directly. The compromised Trivy VS Code extension on Open VSX, in February 2026, spawned five local coding agents, each in its most permissive mode: --dangerously-skip-permissions for Claude Code (plus --add-dir /), --ask-for-approval never with danger-full-access for Codex, --yolo for Gemini and Copilot, and --no-interactive for Kiro. Our analysis summed it up: "Any tool capable of invoking them inherits their reach." This is the territory agentic AI security covers, and least privilege is its first control.

Rule-based permissions are the obvious tool. A deny list in an agent's settings file keeps it away from the files that matter most:

JSON
{
  "permissions": {
    "deny": [
      "Read(~/.ssh/**)",
      "Read(~/.aws/**)",
      "Read(~/.npmrc)",
      "Read(./.env)",
      "Read(./.env.*)"
    ]
  }
}

Rules like these have the same limit as Node's permission model. Claude Code's permissions documentation says deny rules do not apply to "arbitrary subprocesses that read or write files indirectly, like a Python or Node script that opens files itself." For OS-level enforcement it points to its sandbox, which still holds "even if a prompt injection bypasses Claude's decision-making." Prompt injection does not need to escape the agent's privileges. It only needs to use them.

There is a second trap. A policy file is only a boundary if the code it constrains cannot edit it. SANDWORM_MODE, a Shai-Hulud-style worm we analyzed in February 2026, wrote a malicious MCP server into ~/.claude/settings.json, ~/.cursor/mcp.json, and other agent configs it found on disk. Its tool descriptions told the agent to read SSH keys, AWS credentials, and ~/.npmrc and pass them along. Any process running as you can rewrite a file in your home directory. Policy that must hold belongs in an organization-managed location, which Claude Code's settings documentation says nothing in user or project settings can override, or outside the machine entirely.

What works, in rough order of strength:

  • Disposable container or VM, no home directory mounts, one injected token. This is the only option that holds against a fully hijacked agent. It does not protect the repository you mount, or the token you inject.
  • The agent's OS sandbox with a network allowlist, on workstations. It confines shell commands and their child processes. An allowlisted multi-tenant host such as github.com can still receive exfiltrated data.
  • Agent-specific short-lived, narrowly scoped tokens instead of your gh session. A sandbox cannot protect credentials the agent legitimately holds, so scope them per task. As we noted in our coverage of Microsoft's Agent Governance Toolkit, most orchestration frameworks do not support per-task credentials natively yet. A stolen token still works for its lifetime.
  • Deny rules for credential files, and no bypass flags outside disposable environments. Rules stop the agent's own tools. As shown above, they do not stop a script the agent runs.

The trade-off is friction. Every boundary adds approvals or plumbing, and approval fatigue is how people end up typing --yolo. The answer is to make the sandbox the default, so the safe path is also the low-friction one.

What least privilege does not cover

#

Least privilege limits what a stolen credential can do. It does nothing when the attacker holds a legitimate credential and uses it for exactly what it was granted. An attacker who phishes a maintainer with publish rights will publish through the correct pipeline, with a correctly scoped token, and often with valid provenance. Every privilege in that chain was necessary. Our hardening guide calls this the insider half of the problem and treats it as a separate discipline: identity, separation of duty, and detection.

Two consequences follow. First, least privilege and detection are complements, not alternatives. The control that catches a maintainer compromise is analysis of what the published package actually does, not a review of who was allowed to publish it. Second, the boundary the ecosystem still lacks is per-dependency privilege. Saltzer and Schroeder wrote their rule for programs and users. Fifty years later, a modern application is a thousand programs written by strangers, sharing one process and one set of privileges. Until runtimes can grant a dependency less authority than the code that imports it, that boundary is enforced by inspection before install, not by the runtime after.