Your AI Agent Has Access to Everything. Who Is Controlling It?
You build an agent that helps your team with software projects. At first it reads GitHub issues and summarizes pull requests. Then it gets a cloud integration to inspect failed deployments. A month later, someone adds "restart service" because it saves a trip to the console.
Each step was reasonable. The product got better every time. But look at what you now run: a system that interprets instructions, chooses tools at runtime, and calls real infrastructure using credentials your application hands it.
Gartner has predicted that 40% of enterprise apps will embed task-specific agents by 2026, up from under 5% the year before. Most of those agents will be wired up the way yours was: fast, incrementally, and with whatever credential was nearby.
So ask yourself: Which identity does the agent present to GitHub? Can it reach every repo the developer who configured it can reach? Does its cloud credential allow reading state, changing it, or both? Can you tell which human started a given action? If it misbehaves right now, can you cut its access in seconds? If one component is compromised, what else becomes reachable?
None of these are prompt engineering questions. They are identity, authorization, and architecture questions. This post is a practical walkthrough of how to answer them, with the standards that already exist and code you can adapt.
An agent should not hold authority just because it is capable of using it.
1. Why agents strain the old assumptions
Software has always needed identities. A backend authenticates to a database, a CI job pulls from object storage. What changes with agents is that the execution path is partly decided at runtime.
You authorize an agent to "investigate a failed deployment." It reads logs, queries metrics, reads config, and proposes a fix. The task was narrow. The permissions behind it probably were not:
- If it runs on a developer's personal token, it inherits everything that developer can do.
- If it runs on a shared service account, nobody can tell its actions from other automation.
- If it holds a long-lived API key, the key outlives the task, and so does the blast radius of a leak.
This is not a brand-new problem. IAM, OAuth, workload identity, and secrets management already exist. The hard part is applying them to a caller whose next step you cannot predict.
The standards bodies agree. In February 2026, NIST's National Cybersecurity Center of Excellence (NCCoE) published a concept paper proposing a project on applying existing identity standards to software and AI agents. It is a proposal, not a standard. NIST then followed with a two-part blog series: Why Agentic AI Needs a Strong Identity Foundation (August 27, 2026) and Design Tenets for Agent Identity Management Systems (October 27, 2026), which lists ten proposed tenets.
Gartner's guidance points the same way. As summarized by Descope, its January 2026 report recommends prohibiting the sharing of human credentials with agents and requiring a unique identity with an accountable human owner for every agent.
Three independent sources, one message: treat the agent as its own principal.
2. The model proposes. It must not decide.
Before designing permissions, separate the parts that people usually blur into "the agent":
- Component / Job
- Model - Interprets context, suggests the next action
- Agent runtime - Runs the loop, holds state, issues tool calls
- Tool layer - Exposes a fixed set of approved operations
- Identity provider - Establishes which workload is calling
- Authorization layer - Decides whether that identity may do this, here, now
- Resource service - Enforces access on the real data or operation
- Audit system - Records what happened and under whose authority
Take a tool called deleteRepository. The model may conclude that deleting a repo helps its goal. That conclusion is not a permission. NIST's seventh tenet says it directly: the logic that decides what an agent attempts must be architecturally separate from the systems that decide what it is allowed to do.
The post also states that guardrails alone are insufficient. So a system prompt line like "never delete important data" is a hint, not a control. Enforcement belongs where access is actually granted. The component that decides what to attempt must not be the only component deciding what is allowed.
3. An API key is a credential, not an identity
The default pattern looks like this:
const client = new SomeServiceClient({ apiKey: process.env.SERVICE_API_KEY });
Nothing is wrong with API keys where a provider is built around them. The trouble starts when that key becomes the agent's general-purpose authority. NIST's first blog is blunt about why: static API keys and bearer tokens don't prove who is calling, they only prove the caller has the string. Anyone holding it can present it, and the post notes such credentials routinely end up in config files, markdown files, and logs.
Concretely, the key answers one question ("does the caller possess an accepted secret?") and leaves the rest open:
- Which workload is calling?
- Which human or process authorized this task?
- Which resources and operations are in scope?
- How long is the permission valid, and can it be withdrawn?
- Can the action be attributed afterward?
The fix is not "swap the API key for a token." A broadly scoped token is still broad. The shift that matters is from "the agent has a credential" to "the agent has a verifiable identity and a narrow, expiring grant of authority."
4. Give the agent its own identity
Picture three actors in a team: a developer, a PR-review agent, and a release pipeline. If the review agent uses the developer's personal token, audit logs say the developer did things the developer never did. If it uses the shared deploy credential, it blends into other automation.
Give it a dedicated identity instead:
- Identity / Intended authority
- Developer - Whatever their role grants
- PR-review agent - Read approved repos, post review comments
- Release agent - Publish a pre-approved release via a controlled workflow
- Monitoring agent - Read selected metrics and logs
These are examples, not templates. The properties that matter: a distinct identity, a named human or team owner, constrained permissions, and an independent lifecycle. You should be able to disable the agent without disabling its owner, and when an owner leaves, you need a deliberate policy for the agents that depended on them.
For implementation, SPIFFE gives software workloads cryptographic identities (SPIFFE IDs) via SVIDs. Cloud-managed identities and workload identity federation serve the same purpose in single-cloud setups. A small app may need nothing heavier than a managed service identity.
A real example of the payoff: OpenAI supports exchanging a SPIFFE JWT-SVID for a short-lived access token, so the workload holds no long-lived API key at all. Two gotchas from their docs are worth knowing before you try it: the JWT-SVID must include iss, iat, and a kid header, and X.509-SVIDs are not supported as subject tokens.
Setup details are provider-specific, but the pattern generalizes: prove identity through an identity system, receive a credential scoped and timed for the job.
5. Authorize the task, not the toolbox
Least privilege is old advice. What's new is applying it when the caller picks tools on the fly. A PR-review agent needs to read the PR, read relevant files, maybe run approved tests, and post comments. It does not need to delete repositories, change org security settings, mint admin accounts, publish releases, or touch production.
Express the grant as data, scoped to one task, with an expiry, using an allowlist only. A deny-list next to an allowlist adds confusion without adding safety.
type Grant = {
agentId: string;
taskId: string;
initiatedBy: string; // human or scheduler that started the task
resources: string[]; // exact IDs, e.g. "github:acme/payments-api"
actions: Set<string>; // e.g. "pull_request.read"
expiresAt: number; // epoch ms
};
type Verdict = { allow: true } | { allow: false; reason: string };
function decide(grant: Grant | undefined, action: string, resource: string, now = Date.now()): Verdict {
if (!grant) return { allow: false, reason: "no_grant" };
if (now >= grant.expiresAt) return { allow: false, reason: "grant_expired" };
if (!grant.resources.includes(resource)) return { allow: false, reason: "resource_out_of_scope" };
if (!grant.actions.has(action)) return { allow: false, reason: "action_not_granted" };
return { allow: true };
}
Everything not explicitly granted is denied. Missing, expired, or ambiguous means no.
Now the part people skip: where this runs. A policy object is not a boundary. It becomes one only when every tool call passes through it, and when the downstream service enforces its own permissions too.
async function callTool(req: { agentId: string; taskId: string; tool: string; rawArgs: unknown }) {
const tool = toolRegistry.get(req.tool);
if (!tool) {
await audit({ ...req, decision: "deny", reason: "unknown_tool" });
throw new Error("Tool not permitted");
}
const args = tool.schema.parse(req.rawArgs); // validate BEFORE authorizing
const resource = tool.resourceOf(args); // derived by code, never taken from the model
const grant = await grants.lookup(req.taskId, req.agentId);
const verdict = decide(grant, tool.action, resource);
if (!verdict.allow) {
await audit({ ...req, action: tool.action, resource, decision: "deny", reason: verdict.reason });
throw new Error("Action not authorized");
}
if (tool.risk === "high" && !(await approvals.granted(req.taskId, tool.action, resource))) {
await audit({ ...req, action: tool.action, resource, decision: "pending_approval" });
throw new Error("Approval required");
}
// Short-lived credential minted for this call: right audience, right scope.
const credential = await tokens.mint({
agentId: req.agentId,
audience: tool.audience,
scope: tool.action,
resource,
ttlSeconds: 60,
});
await audit({ ...req, action: tool.action, resource, decision: "allow", policy: grant!.taskId });
try {
const result = await tool.execute(args, credential);
await audit({ ...req, action: tool.action, resource, outcome: "success" });
return result;
} catch (err) {
await audit({ ...req, action: tool.action, resource, outcome: "error" });
throw err;
}
}
A few details worth noticing:
- The agent never decides whether its own request is allowed. It submits a request; deterministic policy answers.
- The resource comes from validated arguments and tool code, not from a field the model can write freely.
- The credential is minted per call with a narrow audience and a short TTL, so a leak in one tool does not unlock the rest.
- Denials are logged too. A pattern of denied requests is one of the best early signals you will get.
This is a sketch, not a drop-in library. A production version needs verified caller identity, a real policy engine, structured errors, and a guarantee that no tool can reach a downstream service by a side door. If your target API supports finer-grained authorization than your gateway does, use it. Rich Authorization Requests (RFC 9396) exist precisely because static, coarse scopes were not enough.
6. Delegation: acting for a user is not being the user
A manager asks an agent to build a report from one approved dataset. The manager can read twenty datasets. Should the agent get all twenty? Almost certainly not. "The user can access it" does not imply "every agent acting for the user should."
A delegation model needs to keep three parties distinct: the initiator (human or process), the agent, and the downstream service. OAuth 2.0 Token Exchange (RFC 8693) gives you vocabulary and mechanics for this, and the distinction it draws is worth internalizing:
- Delegation: the agent keeps its own identity and acts with explicitly delegated authority. Both parties are visible in the token.
- Impersonation: the downstream service treats the caller as the original principal. Convenient, but the agent disappears from your audit trail.
For agents, delegation is usually what you want. In RFC 8693 terms, you present the initiator's grant as subject_token and the agent's own identity as actor_token:
POST /oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<task grant issued on behalf of user-123>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&actor_token=<agent's JWT-SVID>
&actor_token_type=urn:ietf:params:oauth:token-type:jwt
&audience=https://analytics.internal
&scope=dataset.read
The issued token can carry both parties, via the act claim:
{
"sub": "user-123",
"act": { "sub": "spiffe://acme.example/agent/reporting" },
"aud": "https://analytics.internal",
"scope": "dataset.read",
"exp": 1794398460
}
The analytics service can now see who started this and which agent is acting, and it can enforce that the scope is only dataset.read.
Two cautions. First, token exchange is only as safe as your authorization server's policy, audience restrictions, and the downstream service's validation. Second, delegation must attenuate: when an agent spawns a sub-agent, the child can hold at most what the parent holds, never more. NIST's tenets state this explicitly. Delegation should narrow authority at every hop, never multiply it.
7. Short-lived is not the same as revocable
Say an agent holds a token valid for months and it leaks through a compromised dependency or a stray log line. The task ended long ago; the token still works. Short-lived credentials shrink that window.
A healthier flow looks like this:
- Establish the workload identity.
- Verify the task and its scope.
- Obtain
Comments
No comments yet. Start the discussion.