DEV Community

Dinesh_gowtham
Dinesh_gowtham

Posted on

How IAM Identity Center Powers Secure Access for Claude AI Agents in Node.js, Explained Simply

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.

Why IAM Identity Center Matters for AI Agents

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:

  1. Zero static keys – the Lambda never stores an access key in code or environment variables.
  2. Least‑privilege – you attach a permission set (a collection of IAM policies) that describes exactly what the agent may do, and the temporary credentials inherit only those rights.

Quick analogy

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.

Setting Up a Permission Set for Machine Access

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:*.

Step‑by‑step

  1. Open the IAM Identity Center console.
  2. Create a new permission set, call it ClaudeAgentAccess.
  3. Attach an inline policy that allows:
  • bedrock:InvokeModel on the Claude model ARN.
  • sts:AssumeRoleWithSAML on the SAML provider you will use.
  • Any read‑only permissions for supporting resources (e.g., secretsmanager:GetSecretValue).
  1. Assign the permission set to a service principal—in practice you’ll map it to a SAML application that your Lambda will use.

Policy‑as‑Code with TypeScript’s 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;
Enter fullscreen mode Exit fullscreen mode

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.

Node.js Lambda: Assuming the Role Programmatically

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:

  1. Retrieve the SAML assertion from Secrets Manager (or another secure store).
  2. Call AssumeRoleWithSAML, passing the SAML assertion and the ARN of the permission set’s IAM role.
  3. STS returns temporary credentials (AccessKeyId, SecretAccessKey, SessionToken) that are valid for up to 1 hour.
  4. Use those credentials to create a Bedrock client and invoke Claude.

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 }),
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

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.

Gotchas specific to Lambda

  • require(esm) in Node 22 – if you import an ESM‑only layer, use import syntax exclusively; mixing require will cause silent failures.
  • SnapStart + VPC – enabling SnapStart on a Lambda that lives inside a VPC gives no cold‑start benefit because the network interface still needs to attach. Keep your Bedrock calls outside a VPC unless you need VPC‑restricted resources.
  • Provisioned Concurrency – turning it on for a short‑lived AI worker can surprise the bill. Test with on‑demand first.

Calling Claude via Bedrock with Secure Credentials

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.

Minimal Claude request

// 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);
Enter fullscreen mode Exit fullscreen mode

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.

Policy‑as‑Code: Using TypeScript’s satisfies for IAM Policies

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");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

The Takeaway

Key points to remember

  • IAM Identity Center can issue short‑lived machine credentials; treat it as a badge‑issuer, not just an SSO portal.
  • A permission set should be tiny—grant only bedrock:InvokeModel and the minimal SAML/SecretsManager actions.
  • AssumeRoleWithSAML returns credentials that expire after 1 hour; the SDK won’t refresh them automatically—handle ExpiredTokenException yourself.
  • In Lambda, keep the code simple: fetch SAML from Secrets Manager, exchange it via STS, then call Bedrock with the temporary creds.
  • Use TypeScript’s satisfies operator to write IAM policies as code, catching mistakes at compile time.
  • Watch out for Lambda‑specific quirks: ESM imports, SnapStart inside VPCs, and unintended Provisioned Concurrency costs.

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.

Top comments (0)