Designing an Agent Identity and Access System: Credential Scoping, Token Lifecycle, and Least-Privilege Enforcement for Production AI Agents
Per-agent scoped credentials, just-in-time token issuance, credential broker architecture, and OWASP Agentic Top 10 mitigations for production AI agent systems. With TypeScript implementations.
When you deploy a human engineer to access your production database, they authenticate as themselves. They have a named identity, a session tied to that identity, and an audit trail that answers the question: who touched what and when. When you deploy an AI agent to do the same task, the instinct is to hand it a service account and move on. That instinct is responsible for most of the security problems showing up in agentic systems right now.
The agent runs for 90 seconds. The token it received is valid for 60 minutes. The scope that token carries was written three months ago by an engineer who needed “some access” to get the prototype working. The agent completes its task and exits. The credential it used stays alive, attached to a shared service account, reachable by any other agent that happens to use the same config.
This is the 30x token exposure window problem, and it is structural, not accidental.
Why Human IAM Doesn’t Map to Agents
Human IAM was designed around sessions that last minutes to hours, identities that are stable over years, and revocation mechanisms driven by human events (termination, role change, breach). The design assumptions are wrong for agents in at least three ways.
Agents are ephemeral but credentials aren’t. An agent task may complete in under two minutes, but a standard OAuth token lives for 3,600 seconds. The gap between task duration and token lifetime is your actual exposure window.
Shared service accounts destroy auditability. When ten agents share one service account, an incident investigation can tell you which account was used, but not which agent, which task, or which orchestrating workflow made the call. You lose the attribution chain entirely.
Scope inheritance amplifies blast radius. A service account scoped to “read all documents” because one legacy workflow needed it now grants that scope to every new agent that uses the account, including agents that only needed access to a single document.
The OWASP Agentic Top 10 (published Q1 2025) lists Privilege Escalation through Ambient Authority as a top-three risk category specifically because of this pattern. Ambient authority means the agent receives whatever permissions its execution context holds, not the minimum permissions the current task requires.
The Core Architecture: Credential Broker
The answer is a credential broker: a single service responsible for issuing short-lived, scoped tokens to agents on demand, revoking them when the task ends, and maintaining an audit trail that links every credential to a specific agent identity and task.
The broker sits between agents and every downstream resource. An agent never holds a long-lived credential. It holds an agent identity token, presents it to the broker with a task scope declaration, and receives a time-boxed credential that expires shortly after the task is expected to complete.
// types.ts
export interface AgentIdentity {
agentId: string;
agentType: string; // e.g. "document-summarizer", "email-drafter"
orchestratorId: string; // which orchestrator spawned this agent
taskId: string;
issuedAt: number; // unix ms
expiresAt: number;
}
export interface CredentialRequest {
agentIdentity: AgentIdentity;
requestedScopes: string[]; // e.g. ["docs:read:single", "email:draft"]
resourceConstraints: Record<string, string>; // e.g. { documentId: "doc-123" }
expectedDurationMs: number;
}
export interface IssuedCredential {
credentialId: string;
agentId: string;
taskId: string;
scopes: string[];
resourceConstraints: Record<string, string>;
token: string;
issuedAt: number;
expiresAt: number;
}
The broker validates the request against an agent policy registry before issuing anything.
// credential-broker.ts
import { createHmac, randomUUID } from "crypto";
interface AgentPolicy {
agentType: string;
allowedScopes: string[];
maxDurationMs: number;
requiresResourceConstraints: boolean;
}
const AGENT_POLICIES: Record<string, AgentPolicy> = {
"document-summarizer": {
agentType: "document-summarizer",
allowedScopes: ["docs:read:single"],
maxDurationMs: 5 * 60 * 1000, // 5 minutes
requiresResourceConstraints: true,
},
"email-drafter": {
agentType: "email-drafter",
allowedScopes: ["email:draft", "contacts:read"],
maxDurationMs: 3 * 60 * 1000, // 3 minutes
requiresResourceConstraints: false,
},
"data-analyst": {
agentType: "data-analyst",
allowedScopes: ["analytics:read:reports", "exports:write:temp"],
maxDurationMs: 15 * 60 * 1000, // 15 minutes
requiresResourceConstraints: true,
},
};
export class CredentialBroker {
private signingSecret: string;
private activeCredentials: Map<string, IssuedCredential> = new Map();
constructor(signingSecret: string) {
this.signingSecret = signingSecret;
}
issue(request: CredentialRequest): IssuedCredential | Error {
const policy = AGENT_POLICIES[request.agentIdentity.agentType];
if (!policy) {
return new Error(
`No policy for agent type: ${request.agentIdentity.agentType}`
);
}
// Validate every requested scope is allowed by policy
const disallowedScopes = request.requestedScopes.filter(
(s) => !policy.allowedScopes.includes(s)
);
if (disallowedScopes.length > 0) {
return new Error(
`Scopes not permitted for ${policy.agentType}: ${disallowedScopes.join(", ")}`
);
}
// Enforce resource constraints when the policy requires them
if (
policy.requiresResourceConstraints &&
Object.keys(request.resourceConstraints).length === 0
) {
return new Error(
`Policy requires resource constraints for ${policy.agentType}`
);
}
const now = Date.now();
const durationMs = Math.min(
request.expectedDurationMs,
policy.maxDurationMs
);
const expiresAt = now + durationMs;
const credentialId = randomUUID();
const payload = JSON.stringify({
credentialId,
agentId: request.agentIdentity.agentId,
taskId: request.agentIdentity.taskId,
scopes: request.requestedScopes,
resourceConstraints: request.resourceConstraints,
expiresAt,
});
const token = createHmac("sha256", this.signingSecret)
.update(payload)
.digest("hex");
const credential: IssuedCredential = {
credentialId,
agentId: request.agentIdentity.agentId,
taskId: request.agentIdentity.taskId,
scopes: request.requestedScopes,
resourceConstraints: request.resourceConstraints,
token: `${credentialId}.${token}`,
issuedAt: now,
expiresAt,
};
this.activeCredentials.set(credentialId, credential);
return credential;
}
revoke(credentialId: string): void {
this.activeCredentials.delete(credentialId);
}
validate(token: string): IssuedCredential | null {
const [credentialId] = token.split(".");
const credential = this.activeCredentials.get(credentialId);
if (!credential) return null;
if (Date.now() > credential.expiresAt) {
this.activeCredentials.delete(credentialId);
return null;
}
return credential;
}
}
Per-Agent Identity Registry
The broker needs to know that the agent presenting the request is who it claims to be. That means agents need stable identities issued at spawn time, not self-asserted at request time.
// agent-identity-registry.ts
import { randomUUID, createHmac } from "crypto";
interface RegisteredAgent {
agentId: string;
agentType: string;
orchestratorId: string;
createdAt: number;
rotatedAt: number;
signingKey: string; // stored server-side only, never returned
}
export class AgentIdentityRegistry {
private agents: Map<string, RegisteredAgent> = new Map();
private masterSecret: string;
constructor(masterSecret: string) {
this.masterSecret = masterSecret;
}
register(agentType: string, orchestratorId: string): AgentIdentity {
const agentId = randomUUID();
const now = Date.now();
// Derive a per-agent signing key so compromise of one agent
// doesn't compromise others
const signingKey = createHmac("sha256", this.masterSecret)
.update(`${agentId}:${now}`)
.digest("hex");
const registered: RegisteredAgent = {
agentId,
agentType,
orchestratorId,
createdAt: now,
rotatedAt: now,
signingKey,
};
this.agents.set(agentId, registered);
const taskId = randomUUID();
const identity: AgentIdentity = {
agentId,
agentType,
orchestratorId,
taskId,
issuedAt: now,
expiresAt: now + 30 * 60 * 1000, // identity token valid 30 min max
};
return identity;
}
deregister(agentId: string): void {
this.agents.delete(agentId);
}
lookup(agentId: string): RegisteredAgent | undefined {
return this.agents.get(agentId);
}
}
Token-Scoping Middleware
Resource servers need to enforce scopes at the call site, not just trust that the caller has the right token. The middleware validates the credential, checks the scope against the operation, and enforces resource constraints.
// scope-enforcement-middleware.ts
import type { Request, Response, NextFunction } from "express";
import { CredentialBroker } from "./credential-broker";
type ScopeRule = {
requiredScope: string;
resourceConstraintKey?: string; // e.g. "documentId"
requestParamKey?: string; // e.g. "id" (from req.params)
};
export function requireScope(
broker: CredentialBroker,
rule: ScopeRule
) {
return (req: Request, res: Response, next: NextFunction): void => {
const authHeader = req.headers["authorization"];
if (!authHeader?.startsWith("Bearer ")) {
res.status(401).json({ error: "Missing bearer token" });
return;
}
const token = authHeader.slice(7);
const credential = broker.validate(token);
if (!credential) {
res.status(401).json({ error: "Invalid or expired credential" });
return;
}
if (!credential.scopes.includes(rule.requiredScope)) {
res.status(403).json({
error: "Insufficient scope",
required: rule.requiredScope,
granted: credential.scopes,
});
return;
}
// Enforce resource-level constraints when present
if (rule.resourceConstraintKey && rule.requestParamKey) {
const constrainedValue =
credential.resourceConstraints[rule.resourceConstraintKey];
const requestedValue = req.params[rule.requestParamKey];
if (constrainedValue && constrainedValue !== requestedValue) {
res.status(403).json({
error: "Resource constraint violation",
allowed: constrainedValue,
requested: requestedValue,
});
return;
}
}
// Attach credential context so downstream handlers can log it
(req as any).agentCredential = credential;
next();
};
}
// Usage on a documents router:
// router.get(
// "/documents/:id",
// requireScope(broker, {
// requiredScope: "docs:read:single",
// resourceConstraintKey: "documentId",
// requestParamKey: "id",
// }),
// getDocumentHandler
// );
A document-summarizer agent that receives a credential scoped to docs:read:single with { documentId: "doc-123" } can read exactly that document. It cannot list documents, cannot read doc-456, and cannot write anything. When the task ends, the orchestrator calls broker.revoke(credentialId) and the window closes immediately, not when the original OAuth TTL expires.
Tradeoffs Table
| Approach | Blast Radius | Auditability | Complexity | Token Lifetime | Best Fit |
|---|---|---|---|---|---|
| Shared service account | Full account scope | Account-level only | Low | Hours | Never in production |
| Per-agent static credentials | Per-agent scope | Agent-level | Medium | Hours (same problem) | Legacy systems with no broker support |
| Broker-mediated short-lived tokens | Per-task scope | Task-level + revocable | High | Minutes | Standard agentic production |
| Zero-standing-privilege (ZSP) | None at rest | Full task + resource | Very high | Seconds (on-demand) | High-compliance, high-risk operations |
Zero-standing-privilege means the agent holds no credentials between task boundaries. Each resource access triggers a real-time policy evaluation and issues a single-use token. The latency cost is real (50-200ms per access depending on broker location) and the operational complexity is significant, but for agents touching financial records, health data, or production infrastructure, the model is worth the overhead.
Production Considerations
Credential rotation without task interruption. Long-running tasks (data pipelines, multi-step research agents) may need credentials that outlast the initial TTL. Design the broker to support mid-task rotation: the agent can call broker.refresh(credentialId) to extend the window without re-requesting the full scope. The refresh requires the original credential to still be valid and the policy to allow extension.
Orchestrator attestation. The broker currently trusts the orchestratorId field in the identity. In production, the orchestrator should sign the identity token with its own key, and the broker should verify that signature before issuing credentials. This prevents a compromised agent from self-reporting a trusted orchestrator identity.
Audit log design. Every broker interaction (issue, validate, revoke, failed validation) must emit a structured log entry that includes: credentialId, agentId, agentType, taskId, orchestratorId, scopes, resourceConstraints, timestamp, and outcome. Feed these to your SIEM. The field combination lets you reconstruct the full access graph for any incident.
OWASP Agentic Top 10 alignment. The broker architecture directly addresses three of the top ten risks. Privilege escalation through ambient authority is blocked by per-task scoping. Insecure credential storage is addressed by removing long-lived credentials from agent environments entirely. Insufficient audit trails are resolved by attaching task and orchestrator identity to every credential at issuance time.
Multi-agent systems. When agents call other agents (A calls B calls C), each hop should require its own credential. The sub-agent should present its own identity to the broker and request only the scopes its sub-task needs. Passing the parent agent’s credential downstream creates credential chaining, which reintroduces the blast-radius problem. The orchestrator spawns B with its own identity; B presents that identity to the broker independently.
Health checks and credential exhaustion. Brokers can become a single point of failure. Run at least two broker replicas behind a load balancer. Implement a local credential cache with a short TTL (under 30 seconds) on each resource server so that a broker blip doesn’t immediately cascade into 503s. Treat broker latency above 100ms p99 as a production incident.
Testing agentic access paths. Write integration tests that verify the broker rejects requests with over-requested scopes, expired identity tokens, missing resource constraints, and invalid agent types. These are the failure modes that matter in production and they are easy to miss if testing only the happy path.
The Underlying Principle
The core insight is that agents are not service accounts. They are ephemeral actors with bounded tasks, and their credentials should reflect that. A credential that lives longer than the task it was issued for is a liability. A scope that covers more than the task requires is an attack surface. A shared identity that multiple agents use is an audit failure.
The broker pattern is the minimal viable answer. It takes the temporal mismatch (2-minute task, 60-minute token) and the scope mismatch (task needs one document, account can read all documents) and resolves both in one place. The complexity cost is real, but the alternative is running production agents with the security posture of a shared SSH key.
Identity is not an afterthought in agentic systems. It’s the load-bearing wall.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.