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:
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.
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:
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.
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.
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:
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."
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.
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:
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.
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:
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.
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:
Pair this with sender-constraining where you can. DPoP (RFC 9449) binds a token to a key held by the caller, which NIST's blog points to as a mitigation for many token-theft scenarios, since a stolen bearer token alone becomes much less useful.
Now the part that bites in practice:
Short-lived does not mean instantly revocable.
A signed token typically stays valid until it expires unless the receiving service also checks revocation (RFC 7009), introspects (RFC 7662), or evaluates access continuously. Flipping a switch in your own dashboard does not recall a token a downstream service already accepted. Even OpenAI's federation rule reference notes that edits to a rule apply to new exchanges, while tokens issued earlier can live out their existing TTL.
So design your kill switch by layers, and know what each one does not do:
| Step | Stops | Does not stop |
|---|---|---|
| Suspend the agent identity | New task starts, new token exchanges | Tokens already issued |
| Deny in the tool gateway | All new tool calls through your gateway | Calls made directly with a leaked token |
| Revoke at the provider (if supported) | The credential itself | Changes already made |
| Kill the running process | The active loop | Side effects already committed |
| Restore from backup / roll back | Damage from completed actions | Nothing, this is recovery, not prevention |
For destructive or high-impact operations, rely on prevention (tight scope, approvals, short TTLs, resource-side limits) rather than emergency revocation after the fact. And test the kill switch before an incident, not during one.
An agent reads issues, emails, web pages, PR comments, support tickets. Any of those can contain instructions aimed at the agent.
This is not hypothetical. Security write-ups of the GitHub MCP "toxic agent flow" attack point to broadly scoped personal access tokens as the root cause: untrusted issue content steered an agent that held far more access than the task needed. The OWASP Secure Coding with AI cheat sheet lists the same class of risk, including malicious PR comments instructing an agent to modify unrelated files or exfiltrate code.
NIST frames this as a confused deputy problem: a user asks the agent to summarize a document, the document says "email the contents to this address," and an agent with drive-read and email-send happily does both. No single control stops that. Distinct identity, scoped grants, separated policy, and deterministic checks have to work together.
The important property: text the agent reads must never be able to rewrite what the agent is permitted to do. If injected content makes the model propose email.send, the gateway still denies it, because the grant never included it.
Be honest about limits, though. This does not make prompt injection go away, and least privilege does not stop leakage through channels the agent is legitimately allowed to use. If an agent can read secrets and post to an external channel, you have a data path regardless of identity. So also:
When a production record disappears, you need answers fast: which agent, which task, which human or schedule started it, which tool, which resource, which policy allowed it, which credential, what outcome.
A useful audit event captures the authorization path without copying sensitive payloads:
{
"event_id": "evt-789",
"timestamp": "2026-11-11T12:00:00Z",
"agent_id": "spiffe://acme.example/agent/deploy",
"task_id": "task-456",
"initiated_by": "user-123",
"delegation_chain": ["user-123", "agent/deploy"],
"action": "deployment.restart",
"resource": "service/staging-api",
"decision": "allow",
"policy_version": "deploy-policy-v3",
"outcome": "success",
"prev_hash": "9f2c..."
}
Generate timestamps and IDs in a trusted component, never from model output. Keep credentials and raw request bodies out of logs.
Make the log tamper-evident and independent of the agent. NIST's tenets call for an append-only record stored separately from the agent, because a compromised agent that can touch its own logs can erase its tracks. A hash chain (prev_hash above) or a write-once store gets you most of the way.
One more reminder: an audit log is evidence, not prevention. You need both.
The reflex is "ask a human every time." It backfires. NIST's first blog warns about consent fatigue and compares it to MFA bombing: when people get flooded with prompts they start clicking "allow" without reading, and the accountability you wanted disappears.
Classify by impact instead:
| Action | Control |
|---|---|
| Read an approved doc | Automatic, scoped grant |
| Read internal project data | Identity plus resource check |
| Open a draft PR | Automatic or policy-based |
| Merge to a protected branch | Required review and branch policy |
| Modify production infrastructure | Explicit approval or a tightly governed pipeline |
| Delete critical data | Strong confirmation, restricted authority, tested recovery |
Approval should add judgment, not substitute for access control. A human clicking "approve" cannot compensate for an agent that can already reach everything.
Human / Scheduler
|
v
Task authority ---- records who started the task and why
|
v
Agent runtime ---- own workload identity (e.g. SPIFFE ID)
|
| proposes action
v
Tool gateway ---- validate args, derive resource
|
v
Policy decision ---- allow / deny / require approval
|
v
Credential mint ---- short-lived, audience- and scope-bound
|
v
Resource service ---- enforces its OWN access rules
|
v
Audit + monitoring ---- append-only, independent of the agent
|
v
Suspend / revoke / recover
Real deployments will merge some boxes, or use cloud-native services for several. That's fine. What matters is that no path exists from a model-generated tool call to a sensitive resource that skips policy.
You don't need a full enterprise identity platform for a prototype. You do need the model in place from day one.
Inventory
Constrain
Enforce at runtime
Fix credentials
Make it auditable
Test the boundaries. Most teams only test the happy path. Test the "no":
it("cannot read a repo outside the grant", async () => {
await expect(
callTool({ agentId: "review-agent", taskId: "t1", tool: "pr.read", rawArgs: { repo: "acme/billing" } })
).rejects.toThrow("Action not authorized");
});
it("cannot act after the grant expires", async () => {
vi.setSystemTime(afterExpiry);
await expect(
callTool({ agentId: "review-agent", taskId: "t1", tool: "pr.comment", rawArgs: { repo: "acme/payments-api", pr: 42 } })
).rejects.toThrow("Action not authorized");
});
Also check: Can a child agent receive more authority than its parent? Does disabling the agent stop new operations? What happens to tokens already issued? Can untrusted document text trigger a privileged call? Do logs separate the agent from the initiating user?
A model can write a SQL query, craft a deploy command, or choose the next tool. That capability does not mean the system should allow the action.
Operating systems learned this long ago: a process may be able to open a file, but permissions decide whether it may. Agents make the lesson more visible because the sequence of actions isn't hardcoded. So the architecture has to bound authority even when the plan changes.
Model-level safeguards and infrastructure-level controls are complements. Instructions guide behavior; runtimes can gate sensitive steps. But permissions, credentials, resource boundaries, and authorization checks belong to the systems that control access.
A shared API key will get a prototype working. It will not answer the questions production asks: who is acting, on whose behalf, against what, under what authority, for how long, and how do we stop it?
Answering them takes distinct agent identities, least-privilege grants, careful delegation, short-lived credentials, runtime policy enforcement, tamper-evident audit trails, and a revocation path you have actually tested. Not every system needs every piece on day one, but every team should know what authority its agents hold and what happens if that authority is abused.
My rule is simple:
Give the agent enough access to finish the task, not enough to control everything it can reach.
A trustworthy agent is not one that usually decides well. It is one whose authority stays bounded even when it decides badly.
RAJश्री
Software Engineer · AI/ML Researcher · Founder, Shree Labs
I’m Rajshree, a Software Engineer and AI-focused builder interested in the intersection of software engineering, artificial intelligence, machine learning, and modern web technologies.
I write about the engineering behind emerging technologies—not simply how to use a tool, but how the systems behind it work, how they should be designed, and what it takes to build software that remains reliable, secure, and responsible as technology evolves.
My work and writing explore AI engineering, software architecture, cybersecurity, machine learning, emerging technologies, and the responsibility of building systems that increasingly influence the world around us.
Shree Labs is a technology and knowledge initiative founded by Rajshree, bringing together engineering, research, education, technology projects, and creative expression.
Its areas of exploration include:
The idea is simple:
Learn. Build. Experiment. Share.
The broader vision is to create a space where technology, engineering, learning, research, and creativity can coexist—and where ideas can move from curiosity to practical work.
🌐 Shree Labs: https://rjshree.com/
💼 LinkedIn: linkedin.com/in/rjshree
💻 GitHub: github.com/itsrjshree
If you find value in this work, follow along for more perspectives on AI engineering, software architecture, machine learning, cybersecurity, modern web development, and the evolving relationship between humans and intelligent systems.
Because engineering is not just about making things work. It is also about understanding what we build, why we build it, and the responsibility that comes with it.
Thanks for reading.
© RAJश्री | Shree Labs