Skip to content
network-securityintermediate#least-privilege#access-control#identity#authorization#hardening

Principle of Least Privilege: Scope, Time, and Blast Radius

How least privilege actually works: scoping a permission by subject, action, resource and condition, deriving policy from observed usage, time-bounding access, dropping process privileges, and the failure modes.

Saltzer and Schroeder wrote least privilege down in 1975 as one of eight design principles: every program and every privileged user should operate using the least set of privileges necessary to complete the job. It is now control AC-6 in NIST SP 800-53 and an opening item in most cloud hardening guides.

The gap between agreeing with the principle and implementing it is the interesting part. Everyone agrees an account should hold only what it needs. Almost nobody can say, for a given account, what it needs. This guide is about closing that gap: how a permission decomposes, how to derive a real policy from evidence, how to time-bound the grants that remain, and what least privilege looks like at the process, application and data layers rather than only at the login screen.

What a permission is actually made of

A permission is a claim with four coordinates. Every authorization system, from Unix file modes to a cloud policy engine, is evaluating some version of these:

  1. Subject. Who or what is asking. A named human, a service account, a workload identity, a process running under a uid.
  2. Action. The verb. Read, write, delete, assume, invoke, execute, approve.
  3. Resource. The specific object the verb applies to. One table, one bucket prefix, one host, one row.
  4. Condition. The context in which the claim holds. Source network, device posture, time of day, transport encryption, whether a second approver signed off.

Over-permissioning is almost always a specific failure in coordinates three and four. The subject is right, the action is roughly right, and then the resource is a wildcard and the condition is absent. s3:GetObject on arn:aws:s3:::*/* is a correct verb pointed at every object in every bucket. GRANT SELECT ON ALL TABLES IN SCHEMA public is the same mistake in SQL. %sudo ALL=(ALL) NOPASSWD: ALL is the same mistake on a host.

A fifth coordinate is implied and usually left blank: duration. Most grants are permanent because the systems that issue them default to permanent. That default is where standing privilege comes from, and standing privilege is what makes a stolen credential valuable at three in the morning six months after the project that justified it ended.

The one-sentence test

For any grant in your environment, you should be able to say: this subject may perform these actions on these resources under these conditions until this date, because of this reason, owned by this person. A grant that cannot fill in all seven fields is a grant nobody can defend in a review.

Deriving a policy from evidence

The reason least privilege stalls is that nobody wants to be the person who broke month-end billing by removing a permission. The way past that is to stop guessing and measure. The loop has four steps and it works the same on a database role, a cloud role, a Kubernetes service account and a sudoers entry.

Step 1: observe. Collect a representative window of what the identity actually did. Thirty days is the usual minimum because it captures monthly jobs; a quarterly reconciliation needs ninety. The source depends on the system: cloud audit logs, database statement logs, auditd records, API gateway logs.

Step 2: reduce to distinct pairs. Collapse the log into the set of distinct (action, resource) pairs observed. This is usually a shockingly small set. A reporting job that holds hundreds of permissions typically exercises fewer than ten.

Step 3: propose and shadow. Write the policy that permits exactly the observed set, then deploy it in whatever dry-run mode the platform offers so denials are logged rather than enforced. Run a full business cycle. Anything legitimate that was missed shows up as a logged denial with the exact action and resource you need to add.

Step 4: enforce and expire. Cut over, and put a review date on the policy so it is re-derived when the workload changes rather than growing by accretion.

A worked example

Take an illustrative reporting service, svc-reporting, that builds a nightly CSV and drops it in a bucket. Its original grants, made during a launch weekend two years ago, were:

-- Original database grant
GRANT USAGE ON SCHEMA public, billing, analytics TO svc_reporting;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public, billing, analytics
  TO svc_reporting;
// Original cloud policy
{ "Effect": "Allow", "Action": "s3:*", "Resource": "*" }

Thirty days of database statement logs (pgaudit, or log_statement set on this role) and cloud audit logs reduce to this distinct set: SELECT on six tables in the analytics schema, and PutObject on one bucket prefix. Nothing else. The derived policy:

REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public, billing, analytics
  FROM svc_reporting;
REVOKE USAGE ON SCHEMA public, billing FROM svc_reporting;
GRANT CONNECT ON DATABASE prod TO svc_reporting;
GRANT USAGE ON SCHEMA analytics TO svc_reporting;
GRANT SELECT ON analytics.orders,
                analytics.order_items,
                analytics.customers_masked,
                analytics.regions,
                analytics.fx_rates,
                analytics.calendar
  TO svc_reporting;
{
  "Effect": "Allow",
  "Action": "s3:PutObject",
  "Resource": "arn:aws:s3:::acme-reports/daily/*",
  "Condition": { "Bool": { "aws:SecureTransport": "true" } }
}

Now count the blast radius, which is the number that makes the argument to a sceptical owner:

MeasureBeforeAfter
Tables reachable214 across public, billing and analytics (including public.users and billing.card_tokens)6, read-only
Write paths in the databaseEvery one of those tables, TRUNCATE includedNone
Buckets reachable471 prefix
Object actions availableRead, write, delete, change policyWrite only
Value of the credential to an attackerFull customer dataset and destructive accessSix analytics tables and one write path

Nothing about the service's behaviour changed. What changed is that stealing this credential now yields six tables of aggregated data instead of the entire production dataset. That difference is the product of least privilege, and it is why the control is judged on blast radius.

Least privilege for people

The human-risk half of this belongs to insider threats, which covers standing administrative rights, shared privileged accounts, permissions accreted across old roles, and access that outlives the job. What follows is the mechanism that answers those: attribution, elevation and expiry.

Attribution on every grant. A grant records the named identity that holds it, the person who approved it and the reason, which is what makes revocation targeted instead of all-or-nothing. It is the same argument that drives the SSH rule of logging in as yourself and elevating, covered in SSH hardening.

Elevation instead of standing power. An administrator holds ordinary privilege by default and requests elevation for the specific task. Just-in-time access implements this: a request elevates the identity for a bounded window (fifteen minutes to a few hours is typical), then revokes automatically. Two things improve at once. The window in which a stolen session is worth anything shrinks to the elevation period, and every elevation produces a record saying who, when and why. Permanent membership in an administrative group produces no such record.

Separation of duties on the sensitive paths. Least privilege limits how much one identity holds. Separation of duties makes sure certain operations cannot complete without a second identity: one person submits a payment and another approves it, one engineer writes the change and another merges it, one pipeline identity signs a build and a human release needs a second person, as in code signing. An account can be minimally privileged and still walk a fraud end to end if nothing forces a second party into the path.

Expiry dates outperform access reviews

A quarterly review that asks managers to confirm their reports' access produces mostly rubber stamps, because the reviewer cannot tell which of forty entitlements are still needed. Expiry dates remove privilege without anyone deciding anything, which is why they outperform reviews by a wide margin. Use reviews to catch what has no expiry, and work to shrink that set.

Least privilege for processes

A running process holds privilege independently of the user who started it, and this is the layer people skip.

The classic case is a service that needs one elevated capability at startup and none afterwards. Binding TCP port 443 requires CAP_NET_BIND_SERVICE on Linux by default, though the privileged range is tunable through net.ipv4.ip_unprivileged_port_start and containers often set it to 0. Serving HTTP requires nothing. Running the whole daemon as root to solve the first requirement hands root to every parser bug in the second.

There are two clean answers. Grant the single capability rather than the whole identity:

# systemd unit
User=svc-web
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
ProtectSystem=strict
PrivateTmp=yes

Or take the privilege, use it, and drop it permanently before touching any attacker-influenced input. The ordering here is a genuine trap:

/* correct order */
if (setgroups(0, NULL) != 0) abort();   /* drop supplementary groups first */
if (setgid(target_gid) != 0) abort();   /* then the primary group */
if (setuid(target_uid) != 0) abort();   /* uid last: it removes the ability to do the others */
 
/* verify: real, effective and saved ids all moved, and no groups survive */
uid_t ru, eu, su; gid_t rg, eg, sg;
if (getresuid(&ru, &eu, &su) != 0) abort();
if (ru != target_uid || eu != target_uid || su != target_uid) abort();
if (getresgid(&rg, &eg, &sg) != 0) abort();
if (rg != target_gid || eg != target_gid || sg != target_gid) abort();
if (getgroups(0, NULL) != 0) abort();   /* supplementary list must be empty */

Calling setuid() before setgid() drops the uid privilege that was required to change the gid, so the group privileges silently remain. The process looks unprivileged and holds group access to whatever those supplementary groups reach. Verify by reading the ids back with getresuid and getresgid and checking that the supplementary group list is empty. A uid-only check, such as attempting setuid(0), misses exactly the group privileges that survive a wrong ordering, and it says nothing at all when the target uid is itself 0.

Privilege dropping is also the layer that turns a race condition from a compromise into a nuisance. A TOCTOU race is won by an attacker who flips a path between the check and the use; if the process holding that file descriptor has already dropped to an unprivileged identity, winning the race yields access the attacker mostly already had. The race is still a bug worth fixing structurally, and least privilege is the layer that decides how much it costs when the fix is missing.

The same logic applies to the exploitation chain generally. Memory-safety bugs execute with the privileges of the process that contained them, which is why process privilege sits alongside the compiler and kernel protections described in exploit mitigations.

Least privilege inside the application

Authorization does not stop at the session. Several layers below it decide how much a legitimate user can reach.

Object level. A user authenticated as themselves must still be checked against the specific object they name. The failure mode is IDOR, where the server honours whatever identifier arrives. This is least privilege expressed per request: the fact that a user may read invoices does not mean they may read invoice 88213.

Field level. The same endpoint frequently serves several roles, and the privilege difference between them is which fields they may write. An administrator may set role and credit_limit on a customer record; the customer may set display_name and email. Binding request input to the model without a per-role field allowlist is mass assignment, and the fix is field-level scope on a shared endpoint rather than a second endpoint.

Row level. Databases can enforce scope directly, so an application bug cannot exceed it:

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id', true)::uuid);

Both details carry weight. PostgreSQL exempts the table owner, roles with BYPASSRLS and superusers from a policy, and application services routinely connect as the owning role. FORCE brings the owner back under the policy; superusers and BYPASSRLS roles stay exempt, so the application must not connect as either. The true second argument makes current_setting return NULL when a session never set app.tenant_id, so a forgetful connection matches zero rows instead of raising an error. With both in place the policy holds when a query is built wrong, which is what makes it a second layer over the application check.

Token scope. An API token carries the same four coordinates in string form. orders:read bounded to one tenant is a narrower prize than a token that can call every endpoint, and the tenant belongs in a claim the server checks per query rather than in the client's word for it. Scope issuance, consent and refresh are the OAuth 2 side of this, and the endpoint-level controls sit in the API security guide.

Broken access control is the top category in the OWASP Top 10, and the practices that keep these checks central and mandatory belong with the rest of secure coding.

The models, compared

Least privilege is a goal. These are the mechanisms available for reaching it, and each fails differently.

ModelHow a decision is madeFitsFails when
Discretionary ACLsAn owner lists who may do what on each objectFilesystems, small object setsThe object count grows; permissions drift per object with no central view
RBACPermissions attach to roles, identities hold rolesStable job functions, regulated environmentsReal needs vary within a role, producing role explosion or over-broad roles
ABACAttributes of subject, resource and context are evaluated per requestFine-grained and contextual policy, multi-tenant systemsAttribute quality is poor; policy becomes hard to reason about or audit
ReBACDecision follows a relationship graph (owner of, member of, parent of)Document and collaboration systems, nested ownershipGraph depth makes reasoning about effective access difficult
Just-in-time elevationNo standing grant; access is requested, bounded and revokedAdministrative and break-glass accessApprovals become reflexive, or the request path is bypassed under pressure

Most environments end up with RBAC for the coarse shape, ABAC conditions for context, and just-in-time on top for anything administrative. That combination gets closer to least privilege than any single model.

Failure modes

The permanent temporary grant. Access granted for an incident at 2am and never removed. This single pattern produces more standing privilege than any other. Attach an expiry at grant time.

The shadow admin path. An identity holds no administrative permissions and holds the ability to assume a role that does, or to modify the policy that governs itself, or to trigger a pipeline that runs as an administrator. Effective privilege is the transitive closure, so enumerate what an identity can reach through assumption, not what its own policy string says.

Read-only that reads secrets. A genuinely read-only role pointed at a secrets store, a configuration table containing tokens, or a CI log stream holding printed credentials is an administrative role wearing a read-only label. Scope by resource sensitivity as well as by verb.

Deny-by-default with a bypass. A carefully scoped policy sitting next to an emergency account with a static password in a password vault that thirty people can open. The weakest path defines the actual privilege level.

Role explosion. Chasing perfect granularity produces four hundred roles nobody understands, and the practical response is that people request the broadest one that works. Aim for a role set small enough that a reviewer can hold it in their head, with conditions and elevation handling the variance.

Audit-mode forever. The policy is derived, deployed in dry-run, and never enforced, because nobody wants to own the cutover. Denials logged with no enforcement are a report, and the privilege reduction is zero until enforcement is on.

Machine identities left out. Human access gets reviewed because humans have managers. Service accounts, CI runners, workload identities and integration tokens usually outnumber humans by an order of magnitude, live longer, hold broader grants, and appear in no review cycle. They are the largest untouched surface in most environments and the natural target once an attacker begins moving laterally.

Measuring whether it is working

Least privilege resists yes/no reporting. These numbers move, which makes them worth tracking quarterly:

  • Standing administrative accounts. Count of identities holding permanent administrative rights. This should trend towards single digits with everything else elevating on request.
  • Median permissions unused in 90 days. Per identity. Cloud providers expose last-accessed data per service; databases expose it through statement logs. A high number is a direct measure of creep.
  • Grants with an expiry. As a percentage of all grants. Permanent-by-default is the thing being fixed.
  • Blast radius of the widest non-administrative credential. How many tables, buckets and hosts the broadest ordinary identity reaches. This is the number an attacker inherits from an average phish.
  • Time from departure to full deprovisioning. Measured in hours, per departure, including service accounts the person owned.

Detection sits alongside measurement. Elevation events, policy modifications, role assumption chains and first-time use of a dormant permission are all high-value signals precisely because a least-privilege environment makes them rare enough to alert on. In an environment where everyone is an administrator, none of these events mean anything.

Common misconceptions

Least privilege prevents breaches. What it does is bound them. The phish still lands, the dependency still executes, the insider still has a valid login, and what differs is what the attacker holds afterwards.

It is a cloud IAM concern. IAM is one layer. The principle applies to database roles, process capabilities, application field scope, network reachability, physical access and API tokens. An immaculate cloud policy set next to a database role with ALL PRIVILEGES has not achieved much, and the cloud-specific mechanics belong with cloud security fundamentals.

It slows engineers down. Standing broad access slows them down differently: through change freezes, through incidents, and through the review burden that broad access creates. A fast elevation path with an expiry is usually faster in practice than a quarterly entitlement request.

Zero trust replaces it. NIST SP 800-207 builds zero trust out of several principles and least privilege is one of them. Per-request verification decides whether to trust the requester; least privilege decides how little that trust is worth if it is misplaced.

A tool delivers it. Policy engines, PAM products and CIEM scanners find over-permissioning and enforce decisions. Deciding what an identity legitimately needs, and removing what it does not, stays a judgement made by people who understand the workload.

Verdict

Least privilege has held since 1975 because it acts on the step every one of these events shares: something now holds a credential, and the only question left is what that credential reaches.

Treat it as an engineering problem with a measurement loop rather than a policy statement. Observe what identities actually use, derive policy from that evidence, shadow it, enforce it, and attach an expiry to everything you grant afterwards. Start with the machine identities, because they are numerous, long-lived, broadly permissioned and reviewed by nobody. The number to watch is blast radius, and the only version of this work that counts is the version where that number falls.

Sources & further reading