KMS Decrypt Grants for Agent Tools: Least Privilege Without Env Keys

10 views
Your coding agent needs a GitHub token, a SaaS API key, or a per-tenant webhook secret to run a tool. The shortcut is stuffing GITHUB_TOKEN=ghp_... into the Lambda environment and giving the execution role kms:Decrypt on *. That shortcut is how one prompt-injected tool call exfiltrates every secret in the account. The unfair advantage is Secrets Manager + KMS grants with encryption context, short-lived grant tokens, and CloudTrail-auditable decrypts scoped to tenantId and toolName — not env plaintext.
⚡ TL;DR: Store tool secrets in Secrets Manager encrypted under CMKs. Issue KMS grants (or role policies) constrained by encryption context (tenantId, toolName). Pass grant tokens into the agent runtime only for the active tool turn. Refuse kms:Decrypt on *. Pair with IAM Condition Keys for Agent Runtimes, Secure AI Sandboxes (ECS), and AI Coding Agent Tool Schemas.

KMS Decrypt Grants for Agent Tools: production guidance

Lambda environment variables are visible to anyone with lambda:GetFunction / console access, often land in CI dumps, and are process-wide: every tool in the runtime can read every secret. Agents multiply blast radius — one compromised tool path sees all env keys.
typescript
// ❌ Plaintext in env — every tool can read it
const githubToken = process.env.GITHUB_TOKEN!;
await fetch("https://api.github.com/repos/acme/app/pulls", {
 headers: { Authorization: `Bearer ${githubToken}` },
});
✅ Fetch from Secrets Manager at tool time, decrypt under a constrained grant, never cache across tenants in global memory without a keyed cache and TTL.

Secrets Manager + CMK with encryption context

Encrypt secrets with a customer managed key. Put encryption context on the secret’s KMS usage so decrypt requires matching context:
  • tenantId — which customer / workspace
  • toolName — which agent tool is allowed to see this secret
  • optionally env = prod | staging
typescript
import {
 SecretsManagerClient,
 CreateSecretCommand,
} from "@aws-sdk/client-secrets-manager";

const sm = new SecretsManagerClient({});

export async function putToolSecret(input: {
 tenantId: string;
 toolName: string;
 secretString: string;
 kmsKeyId: string;
}) {
 const name = `agent/${input.tenantId}/${input.toolName}`;
 await sm.send(
 new CreateSecretCommand({
 Name: name,
 SecretString: input.secretString,
 KmsKeyId: input.kmsKeyId,
 // Secrets Manager attaches context on encrypt; enforce on key policy / grants
 Tags: [
 { Key: "tenantId", Value: input.tenantId },
 { Key: "toolName", Value: input.toolName },
 { Key: "agentSecret", Value: "true" },
 ],
 })
 );
 return name;
}
On the CMK key policy, require encryption context for decrypt:
json
{
 "Sid": "AllowDecryptOnlyWithAgentContext",
 "Effect": "Allow",
 "Principal": { "AWS": "arn:aws:iam::111122223333:role/AgentRuntime" },
 "Action": ["kms:Decrypt", "kms:DescribeKey"],
 "Resource": "*",
 "Condition": {
 "StringEquals": {
 "kms:EncryptionContext:toolName": "github_pr_read",
 "kms:ViaService": "secretsmanager.ap-south-1.amazonaws.com"
 },
 "StringLike": {
 "kms:EncryptionContext:tenantId": "*"
 }
 }
}
Tighten further: prefer StringEquals on tenantId via grants per tenant instead of StringLike on the role policy.

Grants and grant tokens with constraints

KMS grants let you mint a narrow decrypt capability without rewriting the key policy for every tenant. Create a grant that allows Decrypt only when encryption context matches, then pass the grant token into the hot path so eventual consistency on the grant does not race.
typescript
import {
 KMSClient,
 CreateGrantCommand,
 RetireGrantCommand,
} from "@aws-sdk/client-kms";
import {
 GetSecretValueCommand,
 SecretsManagerClient,
} from "@aws-sdk/client-secrets-manager";

const kms = new KMSClient({});
const sm = new SecretsManagerClient({});

export async function withToolSecretGrant<T>(input: {
 keyId: string;
 granteePrincipal: string;
 tenantId: string;
 toolName: string;
 secretId: string;
 fn: (secret: string) => Promise<T>;
}): Promise<T> {
 const grant = await kms.send(
 new CreateGrantCommand({
 KeyId: input.keyId,
 GranteePrincipal: input.granteePrincipal,
 Operations: ["Decrypt"],
 Constraints: {
 EncryptionContextSubset: {
 tenantId: input.tenantId,
 toolName: input.toolName,
 },
 },
 // ✅ Name grants for audit; retire after the tool turn when possible
 Name: `agent-${input.tenantId}-${input.toolName}`.slice(0, 256),
 RetiringPrincipal: input.granteePrincipal,
 })
 );

 try {
 const secret = await sm.send(
 new GetSecretValueCommand({
 SecretId: input.secretId,
 // Grant tokens help immediately after CreateGrant
 })
 );
 // For direct KMS decrypt paths, pass GrantTokens: [grant.GrantToken!]
 const value = secret.SecretString;
 if (!value) throw new Error("empty_secret");
 return await input.fn(value);
 } finally {
 if (grant.GrantId) {
 await kms.send(
 new RetireGrantCommand({
 KeyId: input.keyId,
 GrantId: grant.GrantId,
 })
 ).catch(() => undefined);
 }
 }
}
For high-QPS tools, long-lived grants per tenantId+toolName with a rotator job may be better than create/retire every call — but still never put the plaintext in env.

Wire into the agent tool handler

typescript
export async function githubPrReadTool(ctx: {
 tenantId: string;
 runtimeRoleArn: string;
 kmsKeyId: string;
}, args: { prNumber: number }) {
 // ✅ Schema already validated prNumber; now least-privilege secret fetch
 return withToolSecretGrant({
 keyId: ctx.kmsKeyId,
 granteePrincipal: ctx.runtimeRoleArn,
 tenantId: ctx.tenantId,
 toolName: "github_pr_read",
 secretId: `agent/${ctx.tenantId}/github_pr_read`,
 fn: async (token) => {
 const res = await fetch(
 `https://api.github.com/repos/acme/app/pulls/${args.prNumber}`,
 { headers: { Authorization: `Bearer ${token}`, "User-Agent": "cheatcoders-agent" } }
 );
 if (!res.ok) throw new Error(`github_${res.status}`);
 return res.json();
 },
 });
}
Combine with tag-based IAM so the runtime role can only read secrets tagged agentSecret=true for its tenant — see IAM Condition Keys for Agent Runtimes.

❌ kms:Decrypt on * and other anti-patterns

Anti-pattern Risk Fix
kms:Decrypt Resource * Any CMK ciphertext in account Resource = specific key ARNs
No encryption context Any secret under the key Require tenantId + toolName
Plaintext in env / SSM unencrypted Universal tool access Secrets Manager + CMK
Logging secret values Prompt + log exfil Redact; never EMF the value
Grant without retire / expiry process Privilege pile-up Retire or scheduled revoke
Shared “god secret” for all tools One tool = all SaaS One secret per toolName

Audit with CloudTrail

Enable CloudTrail data events for KMS (and Secrets Manager). Alert on:
  • Decrypt missing expected encryption context keys
  • CreateGrant from principals that are not your grant broker
  • GetSecretValue spikes for a tenant outside normal agent QPS
typescript
// Conceptual: metric filter on CloudTrail → alarm
// eventName=Decrypt && !.requestParameters.encryptionContext.toolName
Ship agent tool failures without echoing secret material. Meter decrypt counts per tenant with CloudWatch EMF — count decrypts, not secret bytes.

Checklist

  • [ ] No tool API keys in Lambda environment variables
  • [ ] Secrets Manager secrets under CMK; tagged with tenantId, toolName
  • [ ] Key policy / grants require encryption context on decrypt
  • [ ] No kms:Decrypt on * — explicit key ARNs only
  • [ ] Grant broker issues constrained grants; retire or rotate
  • [ ] Tool handler fetches secret only inside the tool call scope
  • [ ] CloudTrail alerts on decrypt without context / anomalous GetSecretValue
  • [ ] Sandboxes get task-role credentials, not copied env blobs
Agents without least-privilege secrets are remote shells with your SaaS credentials attached. Grants + encryption context make decrypt an auditable, tenant-scoped event — not a global env read. Related: Agent Tool Allowlists: Least Privilege for Filesystem and Shell Access; Lambda Warm Pools: Low-Latency Backends for Coding Agent Tools; Human-in-the-Loop Gates: Dual Control for Prod-Touching Agent Tools.

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.