Chapter 6 of 7 · 15 min read
Default-deny and brokered credentials
On this page
A never-list is the short document a security team writes when agents start appearing in production: no production credentials, no writes to systems of record, no access to personal data, no autonomous financial actions. Every organisation that ships an agent writes one, and the list is usually correct. It is also, on its own, inert — a wiki page has no way to refuse a pull request that adds an environment variable.
This chapter is about what turns that list into a control. The answer has three parts that only work together: an enforcement point every privileged tool call passes through, a grant record narrow enough to mean something, and an expiry that removes the capability rather than emailing about it. It also covers the one category on the list that gets contested by the teams doing the most useful work, and what to do about the designs that already violate the policy on the day it is approved.
What makes a list a control
A written list and an accumulating pile of credentials are not in tension in the way it first appears. The list states intent; the mechanism is a place in the call path where the default answer is no. Everything else in a never-list is scaffolding around that one property.
The belief worth naming is that the page is doing something, and the test for it is quick. Take the four rules on the wiki page and read them against a service holding two credentials, neither of which they forbid. Now read the identical four rules against the same service holding eleven, several of which they forbid outright. The page is the same page, word for word, in both readings. Nothing about it changed, because nothing about it can: a document has no way to observe a pull request and no way to answer one. A defence whose text is constant while the thing it prohibits goes from absent to routine is not a weak control. It is not in the path at all, and the difference matters, because a weak control gets strengthened while an absent one gets believed. What closes the gap is not better wording. It is the existence of a component that gets asked.
Three pieces have to exist together. There has to be an enforcement point that every privileged tool call passes through, which in practice means credentials are brokered rather than injected — the execution environment holds no secrets, and when it needs to talk to Snowflake it asks a broker, which decides. There has to be a grant record with a scope narrow enough to be meaningful, a named human owner, a justification, and an expiry date. And the expiry has to revoke rather than remind, because an expiry that sends an email creates a queue of emails and no revocations.
# grants/data-assistant.yaml — evaluated by the credential broker on every call
subject: tool:code_exec/data-assistant
default: deny
grants:
- id: G-118
resource: snowflake://analytics/ANALYTICS_RO
operations: [select]
scope: schemas [MART_FINANCE, MART_PRODUCT]; no PII columns (tag: pii=true)
owner: r.okafor@corp # a person, not a team alias
justification: "assistant answers metric questions from the marts"
expires: 2026-10-01 # 12 months; renewal requires re-review
approved_by: platform-security/2025-10-01
- id: G-204
resource: postgres://prod-billing/app_rw
operations: [select, update]
scope: table invoices
owner: UNASSIGNED # broker refuses to load a grant without an owner
justification: "backfill, 2026-01"
expires: 2026-01-31
status: EXPIRED — denied since 2026-02-01, 3,411 denials loggedThe second entry is what a working system looks like when a January backfill is never cleaned up. The grant expired, the broker started denying, the denials were logged, and the assistant lost a capability it should never have had — without anybody noticing, arguing, or writing a ticket. Compare that to the same situation with the credential in an environment variable, where the capability persists silently for as long as the process lives.
The broker itself is unglamorous, which is the point:
def authorize(subject: str, resource: str, operation: str, now) -> Grant:
matches = [
g for g in GRANTS
if g.subject == subject
and g.resource == resource
and operation in g.operations
and g.owner != "UNASSIGNED"
and g.expires > now
]
if not matches:
audit.deny(subject, resource, operation, reason="no live grant")
raise Denied(
f"{subject} may not {operation} on {resource}. "
f"Request a grant: it needs an owner, a scope and an expiry."
)
grant = matches[0]
audit.allow(subject, resource, operation, grant.id)
return mint_short_lived_credential(grant, ttl_seconds=300)Two details in there do most of the work. The credential is minted per call with a five-minute lifetime, so a leaked value from a log or a screenshot is worth almost nothing; a secret can be present in the sandbox without ever being obtainable by it, and that is why a containment statement can say "credentials visible from inside: none" while the tool still reaches Snowflake. And the denial message names the process for getting a grant, because a default-deny system with no obvious path to an exception is one people route around, usually by finding an endpoint that does not go through the broker.
That last risk is worth stating plainly. A default-deny broker only governs the calls that reach it, so a sandbox with unrestricted network egress can talk to Snowflake directly if it ever obtains a credential by any other route. Default-deny at the broker and default-deny at the network are the same control expressed in two places, and either one alone has a hole shaped like the other.
Return to the first of the broker's two details, because it deserves its steps. "Credentials visible from inside: none" and "the tool queries Snowflake" read as a contradiction until the timing is written out. Nothing is stored in the sandbox at any point. When the tool needs the warehouse it asks the broker; the broker checks the grant; a credential is created at that moment with an expiry a few minutes away. The value exists inside the sandbox for the length of the call and then stops being accepted by anything, whether or not anyone deleted it. So the two statements are about different quantities. The containment statement is a claim about what sits at rest inside the boundary, and it is true. The capability is a claim about what can transit the boundary on request, and that is true as well. What makes the arrangement work is that a stolen copy is worth only the seconds remaining on it, so the value of a leak decays to nothing without anybody noticing the leak. Separate the forced part from the chosen part before copying this: brokering is the requirement, because it is what puts a decision in the path, while the length of the lifetime is a decision, traded against how badly a long-running query fails when its credential expires underneath it.
The list, and the item that will be fought
At the level of categories, the list is short enough to defend in a room.
| Category | Default | Exception mechanism | Max duration | Evidence required |
|---|---|---|---|---|
| Long-lived secrets in an execution environment | Deny | Broker-minted short-lived credential instead | n/a — no exception, this is a substitution | — |
| Writes to a system of record | Deny | Staged write plus verified reconciliation, or a human approver | 6 months | Verification design, quarantine owner |
| Financial actions and value movement | Deny | Human approval per action, with a value cap | 3 months | Cap, approver rota, reversal path |
| Personal data at scale | Deny | Column-level scope, row limits, purpose statement | 6 months | Data protection impact assessment (DPIA) reference, retention rule |
| Identity and access changes | Deny | No exception | — | — |
| Pushing to protected branches or deploying | Deny | Proposes a pull request; a human merges | 12 months | Review requirement |
| Sending as a human identity | Deny | Distinct agent identity, visible in the From line | 12 months | Recipient-visible labelling |
Two rows in that table carry no exception mechanism, and they are empty for opposite reasons, which is worth reading rather than skipping past. Identity and access changes have no exception because the answer is no: an agent that can grant permissions can grant itself the permissions any other exception would have required, so a single grant in that row quietly destroys the value of every other row. Long-lived secrets in an execution environment have no exception for the reverse reason. Nothing is being refused there at all; the requirement is met a different way, by a credential minted per call rather than stored, so there is no longer anything to make an exception to. A blank cell that means prohibition and a blank cell that means substitution look identical in a table and behave completely differently in a room, because only one of them has an argument on the other side of it.
The row that gets contested is writes to a system of record, and it gets contested by the teams doing the most useful work, which is what makes it hard. A nightly supplier agent of the kind described earlier in this topic exists precisely to write into an ERP; a flat prohibition either kills it or turns the policy into something people work around.
The case for keeping it is that the exception mechanism is the interesting part. "A human approver" is not the only way to satisfy a second-party requirement; a verifier that compares the write against an independent source satisfies the same intent, which is exactly the reconciliation described in the previous chapter. So the rule to defend is that a production write is either approved by a second party or verified against an independent source, with a bounded blast radius per run and a named owner for the quarantine queue. Stated that way, the nightly agent complies after two days of engineering, and the argument moves from "may we write" to "how do we know it was right", which is the conversation worth having. The cost of the control is bounded and knowable — an afternoon of assertions, one manual triage every few weeks. The cost of skipping it was eleven days and £186,000 of misposted payables in one organisation, and that number is the one to bring to the room, because a policy defended with principles loses to a policy defended with an incident.
The request path, and how the policy is measured
A default-deny system lives or dies on how a legitimate exception is obtained, because the friction of the request path is what determines whether people use it or route around it. The version that worked in one organisation was a pull request against the grants file, with a template that asked for the five fields the broker requires and nothing else, reviewed by one named approver with a two-working-day service level. Nothing in that is sophisticated, and the two properties that made it hold were that the review was a person rather than a committee, and that a grant which sat unapproved for a week was escalated rather than forgotten. Teams tolerate a control that answers them; they route around a control that goes quiet.
The grants file also gives the policy something a wiki page can never have, which is a measurement. Three numbers taken monthly say whether the thing is working. The count of live grants, which should be roughly flat and which rising steadily means the exception process has become the normal process. The count of grants expiring in the next thirty days that have no renewal request, which is the queue of capabilities about to be removed from systems whose owners have not noticed they depend on them. And the denial log, grouped by subject and resource, which is the most informative of the three: a subject generating thousands of denials against one resource is either an attack, a bug, or a team that needed a grant and gave up asking, and all three are worth a conversation. In one case the expired backfill grant generated 3,411 denials over four months without anybody investigating, which was its own finding — the broker was enforcing correctly and nobody was reading what it enforced.
Divide before drawing the conclusion, because the rate says something the total does not. Three and a half thousand denials spread across four months is roughly twenty-eight a day, with no gaps. A person who hits a wall twenty-eight times in one day files a ticket before lunch; a person who hits it twenty-eight times a day for four months does not exist. So the shape of the series identifies the caller without anyone having to look: it is a schedule, a retry loop or a dashboard refresh, something with no human attached, which will go on being denied indefinitely and will never generate a complaint. That is precisely why this number had to be read rather than waited for. The denial log is the one signal in this chapter whose failure mode is silence, because the mechanism is working perfectly and the only party being inconvenienced is a cron entry.
One more property is worth building in from the start: the broker should record which grant authorised each call, so that the audit trail answers "under what authority" rather than only "what happened". An incident review can then say that an assistant read the billing database under grant G-204, approved by a named person in January for a backfill, rather than only that a query ran at some point.
The three designs already in violation
The proposal that gets a policy approved is to grandfather what exists and apply the rules to new work. It is reasonable-sounding, it unblocks the meeting, and it guarantees the policy is decorative from the first day, for three reasons worth saying out loud rather than implying. The existing systems are the ones that have been accumulating authority the longest, so they are the highest-exposure systems in the estate and the exemption is aimed precisely at them. The exemption establishes that the policy is negotiable, which every subsequent team learns within a quarter, and the second exemption is much easier to obtain than the first. And nobody returns to a grandfathered system, because there is never a week in which revisiting a working integration beats shipping something new.
The alternative that holds is to apply the policy to all of them and to make the exception process do its job, which means each violating design gets a written grant with an owner and a date rather than a permanent pass. What makes this workable is that the interim control is usually much cheaper than full compliance, so nothing is blocked this week.
| Design | Violation | Interim control, deployable this week | Full compliance by | Owner |
|---|---|---|---|---|
| Data assistant code-exec | 11 long-lived secrets in env | Broker in front of the 3 in use; delete the other 8 | 2026-05-15 | r.okafor |
| Nightly supplier sync | Unverified ERP writes, broad service account | Per-run value cap, staged commits, header assertions | 2026-06-01 | m.laurent |
| Support copilot | Reads customer personal data (PII) unscoped | Column allowlist, 200-row cap per query | 2026-06-30 | k.deshpande |
Three properties make that table a control rather than a plan. Each row has a person rather than a team, because a team alias cannot be asked a question in three months. Each has a date, and the date is enforced by the same expiry mechanism as everything else, so a design that misses it starts being denied. And the sequencing is by exposure, which puts the eleven secrets first.
The first row of that table also holds the arithmetic worth carrying out of the chapter, and it closes exactly. Eleven credentials divide into three still in use and eight not, with nothing left over, and the eight are dealt with by deletion rather than by governance. That is why an hour of work removed more exposure than anything else on the list: most of the exposure was not in use, so nobody was defending it, and each of the eight carried a full cost with no remaining benefit. The accumulation rate explains why it went unnoticed — two credentials at launch and eleven at the audit means nine arrived across five months, a bit under two a month, which is a rate at which nothing ever looks like an event. Run the same division before designing a control anywhere else. Where the live set is small and the dead set is large, the first move is a deletion, and the broker is what stops the dead set re-forming. Reverse the proportions and the deletion buys almost nothing, the whole cost lands on the grant records, and that is a different week of work with a different argument in it.
A never-list becomes real at the moment something in the call path can answer no without a human being present, and every other part of it — the categories, the durations, the evidence required — is a way of describing the exceptions to that default. Brokered credentials are what make the default cheap enough to live with, because they let a sandbox reach a database while holding nothing that outlives the call, and an expiry that revokes is what stops the exception set from being a record of everything anyone has ever needed.
The reason the default has to be no from the first week rather than from the week after the audit is structural. A general-purpose execution tool is the most reusable thing a platform team ever ships, it is cheap to reach, and it imposes no cost on the person reaching it — so it accretes users faster than it accretes governance, and the eleven credentials are what that looks like after five months of everybody behaving reasonably.
Permissions bound what an agent may do. They say nothing about what it did do when it stopped in the middle, which is the state an on-call engineer finds at three in the morning with forty files half renamed and two emails whose status nobody can determine.
Computer use, browser use and code execution tools — read the current version at https://daviddominguez.dev/learn/en/computer-use-browser-use-and-code-execution-tools/default-deny-and-brokered-credentials