Human users and AI agents both need the right permissions, but mixing SSO with programmatic access is tricky. In just a few minutes you’ll see how to let a Claude‑powered Lambda speak to Bedrock without ever hard‑coding static keys. The pattern works for any LLM‑backed tool you build today.
When a human logs into the AWS console we usually think about IAM Identity Center (formerly AWS SSO) as a single‑sign‑on (SSO) portal. It lets people click a link, type a password, and get a web console with all the permissions they need.
An AI agent—for example a Lambda function that talks to Claude on Bedrock—needs the same “right‑things‑can‑do” guarantee, but it can’t click a button. If you give the function long‑lived static credentials, you expose a secret that never rotates.
In plain English: IAM Identity Center can hand out short‑lived, automatically‑rotated credentials for programs, just like it hands a temporary badge to a visitor.
The benefit is two‑fold:
Think of IAM Identity Center as a hotel front desk. A human guest gets a room key that works for a few hours. An AI agent is a robot concierge that needs a key to fetch a specific amenity (the Claude model). The front desk can issue a temporary key for the robot, and the key expires automatically, keeping the hotel secure.
A permission set groups one or more IAM policies and is assigned to a user or group in IAM Identity Center. For a machine, you create a dedicated permission set that includes only the actions needed to call Bedrock and read any supporting resources (like a Secrets Manager secret that holds the SAML assertion).
Tip: Keep the permission set tiny. If the agent only needs bedrock:InvokeModel, don’t also grant s3:*.
ClaudeAgentAccess.
bedrock:InvokeModel on the Claude model ARN.
sts:AssumeRoleWithSAML on the SAML provider you will use.
secretsmanager:GetSecretValue).
satisfies
Instead of hand‑editing JSON in the console, you can write the policy in TypeScript and let the compiler verify its shape. The satisfies operator ensures the object conforms to the IAM policy schema at compile time.
// src/policy.ts
import type { PolicyDocument } from "aws-sdk/clients/iam";
/**
* Inline policy granting the minimal rights for a Claude‑powered Lambda.
* The `satisfies` keyword makes TypeScript check that every required field exists.
*/
export const claudeAgentPolicy = {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Action: [
"bedrock:InvokeModel", // call Claude on Bedrock
"sts:AssumeRoleWithSAML", // exchange SAML for temporary creds
"secretsmanager:GetSecretValue", // read the SAML assertion stored securely
],
Resource: "*", // you could lock this down to specific ARNs for tighter security
},
],
} satisfies PolicyDocument;
Key takeaway: Using satisfies catches typos (like "bedrock:InvokModel") before you ever deploy, turning a runtime surprise into a compile‑time warning.
You can copy the resulting JSON into the console’s Inline Policy editor when you create the permission set.
Now that the permission set exists, the Lambda must assume the role represented by that set. The standard way is to call the AWS Security Token Service (STS) operation AssumeRoleWithSAML. The flow looks like this:
AssumeRoleWithSAML, passing the SAML assertion and the ARN of the permission set’s AccessKeyId, SecretAccessKey, SessionToken) that are valid for up to 1 hour.
Below is a minimal, fully‑commented Lambda handler written in TypeScript for Node.js 22. It uses the AWS SDK v3 packages you asked for: @aws-sdk/client-sts, @aws-sdk/client-secrets-manager, and @aws-sdk/client-bedrock-runtime. (The client-iam and client-lambda packages are not needed directly in the handler but are listed in the service context for completeness.)
// src/handler.ts
import {
STSClient,
AssumeRoleWithSAMLCommand,
} from "@aws-sdk/client-sts";
import {
SecretsManagerClient,
GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";
import {
BedrockRuntimeClient,
InvokeModelCommand,
} from "@aws-sdk/client-bedrock-runtime";
import type { Handler, Context, Callback } from "aws-lambda";
// Environment variables you set in the Lambda console
const {
SAML_SECRET_ID, // Secrets Manager name that holds the Base64‑encoded SAML assertion
IAM_ROLE_ARN, // The role ARN linked to the permission set
CLAUDE_MODEL_ID, // e.g. "anthropic.claude-v2:1"
REGION = "us-east-1", // Bedrock region
} = process.env;
// Re‑use SDK clients – they are cheap to create and benefit from connection pooling
const sts = new STSClient({ region: REGION });
const secrets = new SecretsManagerClient({ region: REGION });
/**
* Helper: fetch the raw SAML assertion (Base64 string) from Secrets Manager.
*/
async function getSamlAssertion(): Promise<string> {
const cmd = new GetSecretValueCommand({ SecretId: SAML_SECRET_ID });
const resp = await secrets.send(cmd);
if (!resp.SecretString) {
throw new Error("SAML secret is empty");
}
return resp.SecretString;
}
/**
* Helper: exchange the SAML assertion for temporary AWS credentials.
* Throws ExpiredTokenException if the SAML token is older than 1 hour.
*/
async function assumeRoleWithSaml(samlAssertion: string) {
const cmd = new AssumeRoleWithSAMLCommand({
RoleArn: IAM_ROLE_ARN,
PrincipalArn: `arn:aws:iam::${process.env.AWS_ACCOUNT_ID}:saml-provider/YourIdCProvider`,
SAMLAssertion: Buffer.from(samlAssertion).toString("base64"),
DurationSeconds: 3600, // 1 hour, matches the SAML token lifetime
});
const resp = await sts.send(cmd);
if (!resp.Credentials) {
throw new Error("Failed to obtain temporary credentials");
}
return resp.Credentials;
}
/**
* Main Lambda entry point.
* It fetches a SAML token, swaps it for short‑lived credentials,
* then calls Claude on Bedrock with those credentials.
*/
export const handler: Handler = async (
event: unknown,
_context: Context,
_callback: Callback
) => {
try {
// 1️⃣ Pull the SAML assertion (the "badge" the front desk gave us)
const samlAssertion = await getSamlAssertion();
// 2️⃣ Turn the badge into a temporary keycard (AssumeRoleWithSAML)
const tempCreds = await assumeRoleWithSaml(samlAssertion);
// 3️⃣ Build a Bedrock client that uses the temporary credentials
const bedrock = new BedrockRuntimeClient({
region: REGION,
credentials: {
accessKeyId: tempCreds.AccessKeyId!,
secretAccessKey: tempCreds.SecretAccessKey!,
sessionToken: tempCreds.SessionToken!,
expiration: tempCreds.Expiration,
},
});
// 4️⃣ Prepare a simple Claude request
const prompt = "Explain the difference between TCP and UDP in two sentences.";
const payload = {
// Bedrock expects a JSON string for Claude's body
body: JSON.stringify({ prompt, max_tokens_to_sample: 256, temperature: 0.7 }),
modelId: CLAUDE_MODEL_ID,
accept: "application/json",
contentType: "application/json",
};
// 5️⃣ Send the request
const response = await bedrock.send(new InvokeModelCommand(payload));
// 6️⃣ Return the model's answer (or handle streaming in real apps)
const result = Buffer.from(response.body!).toString("utf-8");
console.log("Claude response:", result);
return {
statusCode: 200,
body: result,
};
} catch (error: any) {
// The most common failure is an ExpiredTokenException when the SAML token
// is older than 1 hour. We re‑run the assume flow in that case.
if (error.name === "ExpiredTokenException") {
console.warn("SAML token expired – retrying assume flow");
// In a real Lambda you might loop a couple of times or push to DLQ.
throw error;
}
console.error("Unhandled error:", error);
return {
statusCode: 500,
body: JSON.stringify({ message: error.message }),
};
}
};
Helpful tip: The AWS SDK for JavaScript does not automatically refresh SAML‑derived credentials. If you see ExpiredTokenException, catch it and run the AssumeRoleWithSAML flow again, as demonstrated in the catch block.
import syntax exclusively; mixing require will cause silent failures.
Now that we have a Bedrock client backed by temporary credentials, the actual model call is straightforward. The heavy lifting is in the request payload format, which differs per provider. Claude expects a JSON body with prompt, max_tokens_to_sample, and optional parameters like temperature.
In plain English: Think of the Bedrock client as a mailbox you hand a sealed letter to (the request). The envelope (HTTP headers) tells Bedrock what kind of letter it is (application/json). The temporary credentials are the postage stamp that proves you’re allowed to send mail to that address.
// Inside the Lambda handler – after creating `bedrock` client
const requestPayload = {
body: JSON.stringify({
prompt: "Write a haiku about sunrise over the mountains.",
max_tokens_to_sample: 64,
temperature: 0.5,
}),
modelId: CLAUDE_MODEL_ID,
accept: "application/json",
contentType: "application/json",
};
const result = await bedrock.send(new InvokeModelCommand(requestPayload));
const modelOutput = Buffer.from(result.body!).toString("utf-8");
console.log("Claude says:", modelOutput);
Why we don’t use static keys: The bedrock client receives credentials that expire after an hour. If the Lambda runs longer (e.g., a long‑running batch job), the next iteration will hit ExpiredTokenException. The catch block from the previous section restarts the assume flow, guaranteeing fresh credentials without any human touch.
Earlier we stored the policy in policy.ts. Let’s expand that pattern to a policy generation script that could be run in CI/CD pipelines. The idea is to keep IAM definitions alongside your application code, ensuring they evolve together.
// scripts/build-policies.ts
import { writeFileSync } from "fs";
import { claudeAgentPolicy } from "../src/policy";
// The `as const` assertion preserves literal types for maximum safety.
const policyJson = JSON.stringify(claudeAgentPolicy, null, 2) as const;
// Write the policy to a file that can be fed to `aws iam create-policy`
writeFileSync("./dist/claude-agent-policy.json", policyJson);
console.log("Policy written to ./dist/claude-agent-policy.json");
Running npm run build-policies would generate a JSON file ready for the AWS CLI:
aws iam create-policy \
--policy-name ClaudeAgentInlinePolicy \
--policy-document file://dist/claude-agent-policy.json
Key takeaway: By generating policies from TypeScript, you eliminate drift between code and permissions. The satisfies keyword guarantees the shape matches IAM’s expectations before you ever call the CLI.
Key points to remember
ExpiredTokenException yourself.
By following this pattern, you give your Claude‑powered agents the same security rigor you apply to human users, while staying free of hard‑coded keys and long‑lived secrets. The approach works for any LLM you hook up to Bedrock, making your AI tooling both safe and maintainable.
Transparency notice
This article was written with the help of an AI system — Groq (GPT OSS 120B).
Published: 2026-10-19 · Primary focus: IAM
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.