You’ve probably copied your OpenAI or Claude API key into source code and hoped no one ever sees it. In practice, that mistake leads to accidental leaks and painful rotation cycles. This guide shows a fail‑safe way to keep your LLM credentials hidden, auto‑rotate them, and fetch them efficiently at runtime.
Why Secrets Manager Beats Env‑Vars for LLM Keys
The problem with environment variables
An environment variable (env‑var) is a piece of data that a process reads from its operating system at start‑up. It feels convenient because you can process.env.API_KEY anywhere in your code. The downside is that the value lives in plain text on the host, can be printed in logs, and is often checked into version control by accident.
Think of an env‑var like writing a house key on a sticky note and taping it to the front door. Anyone who can see the door can copy the key.
What Secrets Manager gives you
AWS Secrets Manager (a managed service that stores, rotates, and audits secrets) acts like a digital safe deposit box. Your key never leaves the safe in clear text; the service returns it only after your code authenticates with AWS.
- Encryption at rest – AWS encrypts the secret with a customer‑managed KMS key.
- Fine‑grained access – IAM policies let you decide which Lambda or EC2 instance can read the secret.
- Automatic rotation – You can attach a Lambda that fetches a fresh key from the LLM provider and writes it back.
In plain English: Secrets Manager keeps the secret hidden until the moment you really need it, and it can change the secret for you without you touching the code.
Quick comparison (code)
// ❌ Bad: hard‑coded env‑var (leaks easily)
const apiKey = process.env.CLAUDE_API_KEY;
// ✅ Good: fetch from Secrets Manager (encrypted, auditable)
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
const client = new SecretsManagerClient({ region: "us-east-1" });
async function getKeyFromSM(name: string) {
const cmd = new GetSecretValueCommand({ SecretId: name });
const resp = await client.send(cmd);
return resp.SecretString ?? "";
}
The second snippet never stores the key in the source tree, and AWS logs every access for you to review.
Setting Up Automatic Rotation for an LLM API Key
Why rotate?
LLM providers (OpenAI, Anthropic, etc.) treat API keys like passwords. If a key is exposed, anyone can consume your quota, incur costs, and potentially damage your reputation. Rotating the key regularly limits the window an attacker has.
The rotation workflow
-
Create a secret – Store the initial LLM key as a JSON payload, e.g.
{"key":"sk-abc123"}. - Enable rotation – Point the secret to a Lambda function that knows how to ask the LLM provider for a new key.
- Schedule – AWS runs the Lambda on the interval you choose (e.g., every 30 days).
-
Versioning – Each rotation creates a new version of the secret. The label
AWSCURRENTalways points to the newest version, whileAWSPREVIOUSholds the former one.
During the short moment when the Lambda writes the new version, AWSCURRENT flips to the fresh value before your application has finished using the old one. If your code assumes the secret is still in the old format, you can see JSON parse errors.
Implementing the rotation Lambda (minimal)
// rotate-claude-key.ts
import {
SecretsManagerClient,
GetSecretValueCommand,
PutSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";
import fetch from "node-fetch"; // Node 22 includes fetch, but keep for clarity
const sm = new SecretsManagerClient({ region: "us-east-1" });
export const handler = async (event: any) => {
// 1️⃣ Read the current secret (it contains the old key)
const getCmd = new GetSecretValueCommand({ SecretId: event.SecretId });
const current = await sm.send(getCmd);
const oldPayload = JSON.parse(current.SecretString ?? "{}");
// 2️⃣ Ask Claude for a new key (mocked request)
const resp = await fetch("https://api.anthropic.com/v1/keys/rotate", {
method: "POST",
headers: {
"Authorization": `Bearer ${oldPayload.key}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ reason: "scheduled rotation" }),
});
const newData = await resp.json(); // expects { key: "sk-new..." }
// 3️⃣ Write the new key back to Secrets Manager
const putCmd = new PutSecretValueCommand({
SecretId: event.SecretId,
SecretString: JSON.stringify({ key: newData.key }),
// Mark this version as the new current one
VersionStages: ["AWSCURRENT"],
});
await sm.send(putCmd);
};
Important notes
- Cost awareness – Secrets Manager charges $0.40 per secret each month. Ten micro‑services that each keep its own key would cost $4 / month.
- Lambda latency – The rotation Lambda should finish quickly; otherwise the secret stays in an inconsistent state.
-
Cross‑account access – If your Lambda lives in a different AWS account, you need both a resource policy on the secret and an IAM policy granting
secretsmanager:GetSecretValue.
Tip: Give the rotation Lambda only the permissions it needs (
GetSecretValue,PutSecretValue) and nothing more. The principle of least privilege reduces accidental exposure.
Type‑Safe Retrieval in Node.js Using the satisfies Operator
Why type safety matters
When you fetch a secret you receive a string. Turning that string into a usable object is a classic place where runtime errors happen (e.g., a missing field or a malformed JSON). TypeScript’s satisfies operator lets you describe the shape you expect and have the compiler verify it, without forcing a cast.
Defining the secret shape
// secret-types.ts
export interface ClaudeSecret {
key: string; // the raw Claude API key, starts with "sk-"
}
Retrieval function with satisfies
// getClaudeKey.ts
import {
SecretsManagerClient,
GetSecretValueCommand,
ResourceNotFoundException,
} from "@aws-sdk/client-secrets-manager";
import { ClaudeSecret } from "./secret-types";
const client = new SecretsManagerClient({ region: "us-east-1" });
/**
* Fetches the Claude API key from Secrets Manager.
* Throws if the secret is missing or does not match the expected shape.
*/
export async function fetchClaudeKey(secretName: string): Promise<string> {
try {
const cmd = new GetSecretValueCommand({ SecretId: secretName });
const resp = await client.send(cmd);
const raw = resp.SecretString ?? "{}";
// Parse JSON and assert shape with `satisfies`
const parsed = JSON.parse(raw) as unknown;
if ((parsed as ClaudeSecret).key === undefined) {
throw new Error("Secret does not contain a 'key' field");
}
const secret = parsed satisfies ClaudeSecret; // compile‑time check only
return secret.key;
} catch (err) {
// Gracefully handle the moment a rotation deletes the old version
if (err instanceof ResourceNotFoundException) {
console.warn("Secret not found – possibly during rotation. Retrying later.");
throw err; // caller can decide to retry
}
// Re‑throw any other unexpected errors
throw err;
}
}
Key points
-
ResourceNotFoundExceptionis thrown when the secret version you asked for no longer exists (common during rotation). - The
satisfieskeyword ensuressecrethas akeyproperty of typestring. It does not coerce the value; if the JSON is malformed you still get a runtime error, which we catch early.
In plain English:
satisfiesis like a checklist you hand to the compiler: “Make sure the object looks like this, but don’t change it for me.”
Caching Secrets Locally Without Stale Data
Why cache?
Calling GetSecretValue on every Lambda invocation costs money (the API call itself isn’t free) and adds latency. A simple in‑memory cache can serve the same secret for many requests, but you must avoid serving an outdated key after rotation.
Cache design
- Singleton – One instance per Node process.
- TTL (time‑to‑live) – Refresh after a short interval (e.g., 5 minutes).
-
Version check – Compare the
VersionIdreturned by Secrets Manager; if it changes, replace the cached value immediately.
Implementation
// secretCache.ts
import {
SecretsManagerClient,
GetSecretValueCommand,
GetSecretValueResponse,
} from "@aws-sdk/client-secrets-manager";
import { ClaudeSecret } from "./secret-types";
type CacheEntry = {
secret: ClaudeSecret;
versionId: string; // unique identifier for the secret version
expiresAt: number; // epoch ms when we consider the entry stale
};
class SecretCache {
private client = new SecretsManagerClient({ region: "us-east-1" });
private cache: Map<string, CacheEntry> = new Map();
private readonly ttlMs = 5 * 60 * 1000; // 5 minutes
/** Returns the secret, refreshing it if needed */
async get(secretName: string): Promise<ClaudeSecret> {
const now = Date.now();
const entry = this.cache.get(secretName);
// If we have a fresh entry, return it immediately
if (entry && entry.expiresAt > now) {
return entry.secret;
}
// Otherwise fetch a fresh version from Secrets Manager
const fresh = await this.fetchFromSM(secretName);
this.cache.set(secretName, {
secret: fresh.secret,
versionId: fresh.versionId,
expiresAt: now + this.ttlMs,
});
return fresh.secret;
}
/** Low‑level fetch that also returns the version id */
private async fetchFromSM(name: string): Promise<{ secret: ClaudeSecret; versionId: string }> {
const cmd = new GetSecretValueCommand({ SecretId: name });
const resp: GetSecretValueResponse = await this.client.send(cmd);
const raw = resp.SecretString ?? "{}";
const parsed = JSON.parse(raw) as unknown;
const secret = parsed satisfies ClaudeSecret; // compile‑time shape check
// `VersionId` is guaranteed by the SDK when the secret exists
const versionId = resp.VersionId ?? "";
return { secret, versionId };
}
}
// Export a singleton instance
export const secretCache = new SecretCache();
How it avoids stale data
- The cache expires after 5 minutes, guaranteeing at most that many minutes of lag.
- If a rotation happens and the version id changes, the next call will fetch the new version because the old entry will be older than
ttlMs.
Tip: Adjust the TTL based on your traffic pattern and cost tolerance. For low‑traffic Lambdas, a 1‑minute TTL often balances freshness and price.
Putting It All Together: A Minimal AI Agent That Calls Claude Securely
The goal
We will build a tiny function askClaude that:
- Retrieves (and caches) the Claude API key from Secrets Manager.
- Calls Claude’s
/v1/completeendpoint with a user prompt. - Returns the generated text.
All error handling respects rotation windows and secret‑not‑found cases.
Full example
// aiAgent.ts
import { secretCache } from "./secretCache";
import fetch from "node-fetch"; // Node 22 includes global fetch; import for clarity
const CLAUDE_SECRET_NAME = "prod/claude/api-key";
/**
* Sends a prompt to Claude and returns the model's response.
* The function hides all credential handling behind the cache.
*/
export async function askClaude(prompt: string): Promise<string> {
// 1️⃣ Get a fresh (or cached) API key
const secret = await secretCache.get(CLAUDE_SECRET_NAME);
const apiKey = secret.key;
// 2️⃣ Build the request payload
const body = {
model: "claude-3-sonnet-20240229",
prompt,
max_tokens_to_sample: 256,
};
// 3️⃣ Call Claude's HTTP API
const resp = await fetch("https://api.anthropic.com/v1/complete", {
method: "POST",
headers: {
"x-api-key": apiKey,
"content-type": "application/json",
},
body: JSON.stringify(body),
});
// 4️⃣ Handle HTTP errors (including auth failures during rotation)
if (!resp.ok) {
if (resp.status === 401) {
// Unauthorized – likely because the key rotated while this Lambda was warm
console.warn("Claude returned 401 – key may be stale. Flushing cache and retrying.");
// Force a cache refresh on the next call
secretCache["cache"].delete(CLAUDE_SECRET_NAME);
throw new Error("Authentication failed – retry later");
}
const errBody = await resp.text();
throw new Error(`Claude API error ${resp.status}: ${errBody}`);
}
const result = await resp.json();
return result.completion?.trim() ?? "";
}
/* Example usage (e.g., inside an AWS Lambda handler) */
export const handler = async (event: any) => {
const userPrompt = event.body?.prompt ?? "Tell me a joke.";
try {
const answer = await askClaude(userPrompt);
return { statusCode: 200, body: JSON.stringify({ answer }) };
} catch (e) {
console.error("Failed to get Claude response:", e);
return { statusCode: 500, body: "Internal server error" };
}
};
What happens under the hood
- The first call to
askClaudeloads the key from Secrets Manager and stores it in the in‑memory cache. - Subsequent calls reuse the cached key, saving a network round‑trip and a Secrets Manager bill.
- If Claude rejects the key (common right after rotation), we clear the cached entry so the next request forces a fresh fetch.
Key takeaway: By separating credential retrieval, caching, and API interaction, you keep each piece simple and resilient to rotation quirks.
The Takeaway
- Secrets Manager > env‑vars – it encrypts, audits, and can rotate keys without code changes.
-
Automatic rotation writes a new version, flips the
AWSCURRENTlabel, and may cause a brief mismatch; handle that with retry logic. -
satisfiesoperator gives compile‑time confidence that the secret’s JSON matches the shape you expect. - Cache wisely – a singleton with a short TTL avoids extra charges while still picking up new versions quickly.
- Graceful failure – detect 401 responses from the LLM provider, flush the cache, and let the caller retry.
By following these steps you can store LLM API keys safely, rotate them automatically, and use them in a Node.js (or TypeScript) service without exposing secrets or paying surprise bills. Happy coding!
Transparency notice
This article was written with the help of an AI system — Groq (GPT OSS 120B).
Published: 2026-09-01 · Primary focus: SecretsManager
All code blocks are intended to be correct and runnable, but please verify them
against the official docs for the tools mentioned before using in production.Find an error? Drop a comment — corrections are always welcome.
Top comments (0)