Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 19 min read

Claude IP allowlist: Lambda with a static egress IP

Since November 5, the Claude IP allowlist lets you decide which IP addresses may use the Claude API for your organization at all. The feature looks small, yet I think it matters a lot: an API key that ends up in a log, a

Claude IP allowlist: Lambda with a static egress IP

Since November 5, the Claude IP allowlist lets you decide which IP addresses may use the Claude API for your organization at all. The feature looks small, yet I think it matters a lot: an API key that ends up in a log, a screenshot or a public repo by accident becomes useless on someone else’s machine.

Here is the catch for anyone building serverless: a Lambda function has no fixed source address out of the box. If you switch on the Claude IP allowlist before pinning down the egress IP of your functions, in the worst case you lock yourself out. That is exactly what this post covers, step by step and with code you can deploy right away.

In short

The Claude IP allowlist only lets requests through from public addresses on the list, everything else gets a 403. To make Lambda fit, you route its traffic through a NAT gateway with an Elastic IP and add exactly that address. The biggest pitfall is jobs that run rarely and only surface days later with a 403.

What you will learn in this article

  • What the Claude IP allowlist covers and what it explicitly does not
  • How to give a Lambda function a static egress IP with CDK
  • How to roll out the allowlist without locking yourself out
  • How an alarm reports 403 rejections before your users do
  • What the NAT gateway costs and when it pays off

What the Claude IP allowlist does

The Claude IP allowlist is an organization-level setting in the Claude Console, currently in beta. You add public IPv4 or IPv6 addresses, either one by one (203.0.113.10) or as a CIDR range (203.0.113.0/24). CIDR is the β€œaddress plus prefix length” notation: the smaller the number after the slash, the larger the range. You get up to 1,000 entries, and no entry may be broader than /8 for IPv4 or /32 for IPv6.

When a request comes from an address that is not on the list, the API rejects it with HTTP 403 and the error type permission_error. According to the documentation, the message reads β€œYour IP address isn't in this organization's allowed range.” One thing matters for your error handling: wording like this can change, so check status and type in your code, not the text.

Which access paths are covered

For organizations on the Claude Platform, the list applies to the Console itself and to the API endpoints you call with an API key or a Workload Identity Federation token: Messages, Message Batches, Token Counting, Models, Files and Skills. On top of that come the Managed Agents endpoints as well as the Admin API and Compliance API, as long as the key belongs to this organization only.

Access path Protected by the allowlist?
Claude API directly (api.anthropic.com) with an API key Yes
Claude Console in the browser Yes
Admin API and Compliance API (key for this organization only) Yes
Claude in Amazon Bedrock No, IAM controls access there
Claude Platform on AWS No
Claude on Google Cloud (Vertex AI) and in Microsoft Foundry No

Coverage according to the Anthropic documentation as of November 9, 2026 (beta)

For AWS folks, the Bedrock row is the interesting one. If you use Claude via Bedrock, you have already solved the β€œleaked API key” problem differently: access depends on IAM roles there, and conditions such as aws:SourceVpce or aws:SourceIp in your policies restrict where calls may come from. So the new Claude IP allowlist mainly concerns you when your functions talk to the Claude API directly.

Why Lambda has no static IP

Without a VPC configuration, a Lambda function runs in a network that AWS manages. Outbound connections then come from a large, shared address pool, and you can neither predict nor reserve the address you get. Dumping those ranges into the Claude IP allowlist would not solve anything: they are huge, and other AWS customers use them just as much. An attacker holding your key would only need to start a function of their own in the same region.

You may know the usual fix from partner APIs that also require IP allowlisting. You attach the function to private subnets of your own VPC and send outbound traffic through a NAT gateway. That is a managed service that translates private addresses into a public one. This public address is an Elastic IP, a static IPv4 address that belongs to your account until you release it. Exactly this one address goes into the Claude Console.

🎬 Flow: a Lambda request passing the Claude IP allowlist – this animated diagram only runs on my site: watch it on sascha-ms.de

One trap right away, because the AWS documentation states it explicitly: attaching the function to a public subnet does not give it internet access. Lambda functions get no public address there. It has to be private subnets whose default route points to the NAT gateway.

The example architecture at a glance

As an example, I use a fictional bike rental. The workshop writes free-form notes for each bike, and a Lambda function has Claude summarize them in three bullet points. It is deliberately small so the focus stays on the networking part. The stack consists of these pieces:

  • a VPC with two public and two private subnets,
  • a NAT gateway with an Elastic IP that you control yourself,
  • the function SummarizeRepairNotesFn, which reads the API key from Secrets Manager,
  • a small check function EgressIpCheckFn that reveals its egress IP,
  • a metric filter with an alarm that counts every rejection by the allowlist.

Set up Lambda with a static IP for the Claude IP allowlist

Prerequisites

You need Node.js 22 or later, the AWS CLI with credentials for a test account, a one-time cdk bootstrap in the target region and an API key from the Claude Console. For the allowlist itself, you must be an admin or owner of the organization in the Console. I tested the code with aws-cdk-lib 2.275.0, @anthropic-ai/sdk 1.6.0 and TypeScript 7.0.

1. Create the project

mkdir claude-ip-allowlist-lambda && cd claude-ip-allowlist-lambda
npm init -y
npm pkg set type=module
npm install @anthropic-ai/sdk @aws-sdk/client-secrets-manager aws-cdk-lib constructs
npm install -D typescript tsx esbuild aws-cdk @types/node @types/aws-lambda

Add a tsconfig.json and a cdk.json. Both are short:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noEmit": true,
    "types": ["node", "aws-lambda"]
  },
  "include": ["bin", "lib", "src", "scripts"]
}
{
  "app": "npx tsx bin/app.ts"
}

2. Store the API key in Secrets Manager

The key belongs neither in your code nor in a plain-text environment variable. So create it once via the CLI. In many shells, the leading space keeps the command out of your history:

 aws secretsmanager create-secret --name bike-rental/claude-api-key --secret-string "sk-ant-..."

3. Write the CDK stack with a NAT gateway

The core is the Elastic IP that you create yourself and hand to the NAT gateway via eipAllocationIds. If you left it to CDK, the gateway would get an Elastic IP as well, but one that disappears when the VPC is rebuilt. With RemovalPolicy.RETAIN, the address stays in your account even if someone deletes the stack.

// lib/claude-egress-stack.ts
import { CfnOutput, Duration, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib';
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
import { Construct } from 'constructs';

export class ClaudeEgressStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // Static egress IP: the Elastic IP survives even a stack deletion,
    // so the address in the Claude allowlist never changes unnoticed.
    const egressIp = new ec2.CfnEIP(this, 'ClaudeEgressIp', {
      domain: 'vpc',
      tags: [{ key: 'Name', value: 'bike-rental-claude-egress' }],
    });
    egressIp.applyRemovalPolicy(RemovalPolicy.RETAIN);

    // One NAT gateway in one AZ: cheap, but a single point of failure.
    const vpc = new ec2.Vpc(this, 'BikeRentalVpc', {
      maxAzs: 2,
      natGateways: 1,
      natGatewayProvider: ec2.NatProvider.gateway({
        eipAllocationIds: [egressIp.attrAllocationId],
      }),
      subnetConfiguration: [
        { name: 'public', subnetType: ec2.SubnetType.PUBLIC, cidrMask: 24 },
        { name: 'app', subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, cidrMask: 24 },
      ],
    });

    // Create the API key yourself beforehand (see step 2 in the article).
    const apiKey = secretsmanager.Secret.fromSecretNameV2(
      this,
      'ClaudeApiKey',
      'bike-rental/claude-api-key',
    );

    const logGroup = new logs.LogGroup(this, 'RepairNotesLogs', {
      retention: logs.RetentionDays.TWO_WEEKS,
      removalPolicy: RemovalPolicy.DESTROY,
    });

    const common = {
      runtime: lambda.Runtime.NODEJS_24_X,
      architecture: lambda.Architecture.ARM_64,
      vpc,
      vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
      bundling: { minify: true },
    };

    const summarize = new NodejsFunction(this, 'SummarizeRepairNotesFn', {
      ...common,
      entry: 'src/summarize-repair-notes.ts',
      timeout: Duration.seconds(60),
      memorySize: 512,
      logGroup,
      environment: {
        CLAUDE_SECRET_ID: apiKey.secretName,
        CLAUDE_MODEL: 'claude-haiku-5-5',
      },
    });
    apiKey.grantRead(summarize);

    const whoAmI = new NodejsFunction(this, 'EgressIpCheckFn', {
      ...common,
      entry: 'src/egress-ip.ts',
      timeout: Duration.seconds(10),
    });

    // Every rejection by the allowlist becomes a metric – and an alarm.
    const blocked = logGroup.addMetricFilter('IpBlockedFilter', {
      filterPattern: logs.FilterPattern.allTerms('claude_ip_blocked'),
      metricNamespace: 'BikeRental/Claude',
      metricName: 'IpAllowlistBlocked',
      metricValue: '1',
    });
    new cloudwatch.Alarm(this, 'IpBlockedAlarm', {
      metric: blocked.metric({ statistic: 'Sum', period: Duration.minutes(5) }),
      threshold: 1,
      evaluationPeriods: 1,
      comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
      treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
      alarmDescription: 'Claude API rejects requests: egress IP missing from the allowlist?',
    });

    new CfnOutput(this, 'EgressIpForAllowlist', {
      value: `${egressIp.attrPublicIp}/32`,
      description: 'Add this entry in the Claude Console under Identity and access',
    });
    new CfnOutput(this, 'SummarizeFunctionName', { value: summarize.functionName });
    new CfnOutput(this, 'EgressCheckFunctionName', { value: whoAmI.functionName });
  }
}
// bin/app.ts
import { App } from 'aws-cdk-lib';
import { ClaudeEgressStack } from '../lib/claude-egress-stack.js';

const app = new App();
new ClaudeEgressStack(app, 'BikeRentalClaudeEgress', {
  env: { region: process.env.CDK_DEFAULT_REGION ?? 'eu-central-1' },
});

Make two decisions consciously. A single NAT gateway is cheap, but it lives in exactly one Availability Zone. If that zone fails, the function loses its way out. With natGateways: 2 and two Elastic IPs you are more resilient, but then you have to add both addresses. Also note: the retained Elastic IP is not reused when you deploy again after a deletion. For production, I would therefore reserve the address once outside the stack and pass its allocation ID via context.

4. The Lambda function with clean 403 handling

On its first invocation, the function fetches the key from Secrets Manager and keeps the client for further invocations of the same instance. The interesting part is the catch block: for HTTP 403, the SDK throws a PermissionDeniedError, and its type field holds the error type from the response. If both match, the function writes a log line with the marker claude_ip_blocked, which the alarm reacts to later.

// src/summarize-repair-notes.ts
import Anthropic from '@anthropic-ai/sdk';
import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager';

const secrets = new SecretsManagerClient({});
let client: Anthropic | undefined;

async function getClaude(): Promise<Anthropic> {
  if (client) return client;
  const res = await secrets.send(
    new GetSecretValueCommand({ SecretId: process.env.CLAUDE_SECRET_ID }),
  );
  if (!res.SecretString) throw new Error('Secret does not contain an API key');
  client = new Anthropic({ apiKey: res.SecretString, maxRetries: 2 });
  return client;
}

// 403 + permission_error is what the IP allowlist returns.
// It can also mean other permission problems – so we log it as a hint.
export function isLikelyIpBlock(err: unknown): err is InstanceType<typeof Anthropic.APIError> {
  return err instanceof Anthropic.PermissionDeniedError && err.type === 'permission_error';
}

interface RepairNotesEvent {
  bikeId?: string;
  notes?: string;
}

export const handler = async (event: RepairNotesEvent) => {
  if (!event.notes) return { statusCode: 400, body: 'Field "notes" is missing' };

  try {
    const claude = await getClaude();
    const message = await claude.messages.create({
      model: process.env.CLAUDE_MODEL ?? 'claude-haiku-5-5',
      max_tokens: 400,
      messages: [
        {
          role: 'user',
          content:
            'Summarize these workshop notes for a rental bike in at most three bullet points ' +
            `and list any missing spare parts:\n\n${event.notes}`,
        },
      ],
    });
    const summary = message.content
      .flatMap((block) => (block.type === 'text' ? [block.text] : []))
      .join('\n');
    return { statusCode: 200, body: JSON.stringify({ bikeId: event.bikeId, summary }) };
  } catch (err) {
    if (isLikelyIpBlock(err)) {
      console.error(
        JSON.stringify({
          event: 'claude_ip_blocked',
          requestId: err.requestID,
          hint: 'Compare the egress IP with the allowlist in the Claude Console',
        }),
      );
      return { statusCode: 502, body: 'Claude rejected the request (IP allowlist?)' };
    }
    throw err;
  }
};

However, permission_error is not an exclusive signal for the allowlist. A key without the right permissions can trigger this type as well. That is why the function is called isLikelyIpBlock and the log entry is a hint, not a diagnosis. The egress IP check function is even shorter:

// src/egress-ip.ts
// Shows which public IP this Lambda function uses to reach the internet.
export const handler = async (): Promise<{ egressIp: string }> => {
  const res = await fetch('https://checkip.amazonaws.com', {
    signal: AbortSignal.timeout(5000),
  });
  if (!res.ok) throw new Error(`checkip answered with status ${res.status}`);
  return { egressIp: (await res.text()).trim() };
};

5. Deploy and check the egress IP

npx tsc --noEmit
npx cdk deploy
aws lambda invoke --function-name <EgressCheckFunctionName> out.json && cat out.json

After the deployment, CDK shows the value EgressIpForAllowlist under Outputs, for example 203.0.113.10/32. The check function must return exactly the same address without /32. If it times out instead, the route to the internet is missing: usually the function sits in the wrong subnet, or the security group does not allow outbound traffic.

Variant with the Serverless Framework

If your account already has a VPC with a NAT gateway, for instance because another platform manages it, you do not need to rebuild the networking part. With the Serverless Framework, you simply attach the functions to the existing private subnets. The framework adds the permissions for the network interfaces itself as soon as a vpc block is set:

# serverless.yml
service: bike-rental-repair-notes
frameworkVersion: '4'

provider:
  name: aws
  runtime: nodejs24.x
  architecture: arm64
  region: eu-central-1
  # Existing private subnets whose 0.0.0.0/0 route points to a NAT gateway with an Elastic IP
  vpc:
    securityGroupIds:
      - ${param:lambdaSecurityGroupId}
    subnetIds:
      - ${param:privateSubnetA}
      - ${param:privateSubnetB}
  iam:
    role:
      statements:
        - Effect: Allow
          Action: secretsmanager:GetSecretValue
          Resource: arn:aws:secretsmanager:${aws:region}:${aws:accountId}:secret:bike-rental/claude-api-key-*

functions:
  summarizeRepairNotes:
    handler: src/summarize-repair-notes.handler
    timeout: 60
    memorySize: 512
    environment:
      CLAUDE_SECRET_ID: bike-rental/claude-api-key
      CLAUDE_MODEL: claude-haiku-5-5
  egressIpCheck:
    handler: src/egress-ip.handler
    timeout: 10

You pass the IDs as parameters, for example with serverless deploy --param="privateSubnetA=subnet-0abc...". Before that, check which Elastic IP is attached to the NAT gateway in these subnets’ routes. Exactly that one belongs in the Claude IP allowlist.

Roll out the Claude IP allowlist without locking yourself out

Technically, switching it on is one click. Organizationally, it is more of a small migration project, because the Console only checks your own current address when you save. Whether your CI pipeline, your team’s laptops or the nightly batch job get through is not checked. This is how I would go about it:

Four steps to the Claude IP allowlist: inventory, pin down addresses, check the list, switch on and watch the alarm

Four steps so no caller is surprised once the list is active

  1. Take inventory: List every place that calls the Claude API with this organization’s keys. Lambda functions, containers, CI runners, local development, scripts on individual machines. Jobs that only run weekly or monthly are the ones most often overlooked.
  2. Pin down addresses: Each of these places needs a static, public egress address, meaning a NAT gateway, a company proxy or a VPN. Private ranges like 10.0.0.0/8 are no use, since the API only sees the public address.
  3. Check the list: Run the planned list through the check script further down, together with all addresses that must be covered.
  4. Switch on and watch: In the Console, turn on the IP allowlist under β€œIdentity and access”, tick the box that lets admins reach the setting from any address, and save. Changes can take up to 15 minutes. After that, watch the alarm for at least one full cycle of your rarest jobs.

One more trade-off about the admin box: it protects you from locking yourself out, but it opens a back door. Anyone who takes over an admin’s session can change the list from anywhere. I would still tick it, but protect admin accounts with strong sign-in. Without it, your only option in an emergency is Anthropic support.

Check the list before saving

Typos in an IP list only show up once something breaks. That is why I wrote a small script that checks a text file of entries. It reports invalid addresses, ranges that are too broad, private networks, duplicates and more than 1,000 entries. You also pass addresses that must be covered, for example the Elastic IP from step 5. It only uses BlockList from the Node.js module node:net, so no extra packages:

// scripts/check-allowlist.ts
// Checks an allowlist file before you copy it into the Claude Console.
import { BlockList, isIP } from 'node:net';
import { readFileSync } from 'node:fs';

export const MAX_ENTRIES = 1000;

interface ParsedEntry {
  address: string;
  prefix: number;
  family: 'ipv4' | 'ipv6';
}

export function parseEntry(entry: string): ParsedEntry | undefined {
  const [address, rawPrefix] = entry.trim().split('/');
  const version = isIP(address ?? '');
  if (version === 0) return undefined;
  const family = version === 4 ? 'ipv4' : 'ipv6';
  const max = version === 4 ? 32 : 128;
  const prefix = rawPrefix === undefined ? max : Number(rawPrefix);
  if (!Number.isInteger(prefix) || prefix < 0 || prefix > max) return undefined;
  return { address, prefix, family };
}

// Private and reserved networks do not belong in the allowlist.
const notPublic = new BlockList();
for (const [net, prefix] of [
  ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
  ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.168.0.0', 16],
] as const) notPublic.addSubnet(net, prefix, 'ipv4');
for (const [net, prefix] of [['fc00::', 7], ['fe80::', 10], ['::1', 128]] as const) {
  notPublic.addSubnet(net, prefix, 'ipv6');
}

export interface Finding {
  entry: string;
  problem: string;
}

export function checkAllowlist(entries: string[], mustBeCovered: string[] = []): Finding[] {
  const findings: Finding[] = [];
  const seen = new Set<string>();
  const allowed = new BlockList();

  if (entries.length > MAX_ENTRIES) {
    findings.push({ entry: '(gesamt)', problem: `${entries.length} entries, the limit is ${MAX_ENTRIES}` });
  }

  for (const entry of entries) {
    const parsed = parseEntry(entry);
    if (!parsed) {
      findings.push({ entry, problem: 'not a valid IP address or CIDR notation' });
      continue;
    }
    const key = `${parsed.address.toLowerCase()}/${parsed.prefix}`;
    if (seen.has(key)) findings.push({ entry, problem: 'duplicate' });
    seen.add(key);

    const minPrefix = parsed.family === 'ipv4' ? 8 : 32;
    if (parsed.prefix < minPrefix) {
      findings.push({ entry, problem: `too broad, needs at least /${minPrefix}` });
    }
    if (notPublic.check(parsed.address, parsed.family)) {
      findings.push({ entry, problem: 'private or reserved network' });
    }
    allowed.addSubnet(parsed.address, parsed.prefix, parsed.family);
  }

  for (const ip of mustBeCovered) {
    const version = isIP(ip);
    if (version === 0) {
      findings.push({ entry: ip, problem: 'address to check is not a valid IP' });
    } else if (!allowed.check(ip, version === 4 ? 'ipv4' : 'ipv6')) {
      findings.push({ entry: ip, problem: 'not covered by any entry' });
    }
  }
  return findings;
}

// Usage: npx tsx scripts/check-allowlist.ts allowlist.txt 203.0.113.10 198.51.100.7
if (process.argv[1]?.endsWith('check-allowlist.ts')) {
  const [file, ...mustBeCovered] = process.argv.slice(2);
  if (!file) {
    console.error('Usage: check-allowlist.ts <file> [IP that must be covered …]');
    process.exit(2);
  }
  const entries = readFileSync(file, 'utf8')
    .split(/[\n,]/)
    .map((line) => line.replace(/#.*/, '').trim())
    .filter(Boolean);
  const findings = checkAllowlist(entries, mustBeCovered);
  for (const f of findings) console.log(`βœ— ${f.entry}: ${f.problem}`);
  console.log(findings.length === 0 ? `βœ“ ${entries.length} entries, all good` : `${findings.length} problem(s) found`);
  process.exit(findings.length === 0 ? 0 : 1);
}

Here is a run with a typical mistake. The file contains the VPC network, and the CI runner’s address is missing:

npx tsx scripts/check-allowlist.ts allowlist.txt 203.0.113.10 192.0.2.44
# βœ— 10.0.0.0/8: private or reserved network
# βœ— 192.0.2.44: not covered by any entry
# 2 problem(s) found

The tests run with the built-in Node.js test runner, without any extra framework. Run them with node --import tsx --test scripts/*.test.ts:

// scripts/check-allowlist.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { checkAllowlist, parseEntry } from './check-allowlist.js';

test('accepts single addresses and CIDR ranges', () => {
  assert.deepEqual(checkAllowlist(['203.0.113.10', '198.51.100.0/24', '2001:db8::/32']), []);
});

test('detects invalid entries', () => {
  assert.equal(parseEntry('203.0.113.300'), undefined);
  assert.equal(parseEntry('203.0.113.0/33'), undefined);
  assert.match(checkAllowlist(['bike.example'])[0].problem, /not a valid/);
});

test('reports ranges that are too broad', () => {
  const [finding] = checkAllowlist(['44.0.0.0/7']);
  assert.match(finding.problem, /too broad, needs at least \/8/);
  assert.match(checkAllowlist(['2001:db8::/31'])[0].problem, /at least \/32/);
});

test('reports private networks and duplicates', () => {
  const problems = checkAllowlist(['10.20.0.0/16', '203.0.113.10', '203.0.113.10']).map((f) => f.problem);
  assert.deepEqual(problems, ['private or reserved network', 'duplicate']);
});

test('checks whether egress IPs are covered', () => {
  const findings = checkAllowlist(['203.0.113.0/28'], ['203.0.113.10', '203.0.113.20']);
  assert.deepEqual(findings, [{ entry: '203.0.113.20', problem: 'not covered by any entry' }]);
});

test('reports more than 1,000 entries', () => {
  const entries = Array.from({ length: 1001 }, (_, i) => `203.0.${Math.floor(i / 250)}.${(i % 250) + 1}`);
  assert.match(checkAllowlist(entries)[0].problem, /1001 entries/);
});

This way you can also check the list in your CI pipeline before anyone changes it in the Console.

What does the static IP cost?

The NAT gateway is not a bargain. For US East (Ohio), the AWS pricing page lists 0.045 US dollars per hour and 0.045 US dollars per processed gigabyte. Then there is the public IPv4 address at 0.005 US dollars per hour. A gateway running around the clock comes to roughly 33 dollars a month, the address to about 3.65 dollars. Prices in Frankfurt are slightly higher, so check the current pricing page for your region.

Data volume is rarely the issue with LLM calls, as prompts and responses are usually just a few kilobytes. It gets more expensive when large downloads or S3 traffic also flow through the same gateway. For S3 and DynamoDB, there are free gateway endpoints. For Secrets Manager, you can create an interface endpoint, but that is billed per hour as well.

My take: if you already have a VPC with a NAT gateway, the Claude IP allowlist is practically free and a clear win. If you have to introduce the gateway just for this, 35 to 40 dollars a month per environment is still modest compared to what a misused key can burn in tokens within a few hours. For pure sandboxes, I would rather rely on short key lifetimes, which Anthropic recommends as a complement anyway.

If you only need the NAT gateway for part of your traffic, take a closer look at the bill: I show how to cut NAT gateway costs with gateway endpoints or IPv6 in Cut NAT gateway costs: cheaper Lambda in a VPC.

Common mistakes and how to avoid them

The weekly job breaks on Monday. Everything works, you are happy, and a week later the report job fails with a 403. That is exactly what the alarm in the stack is for. Add an SNS action with an email or chat notification to the alarm, otherwise it blinks unnoticed in the console.

Dual-stack subnets. If you allow IPv6 for the function in a dual-stack subnet, outbound IPv6 traffic can go through an egress-only internet gateway instead of the NAT gateway. Then you will see a different source address than the Elastic IP. In that case, use the check function to see which address actually arrives.

The shared proxy address. If the list contains the address of a proxy that many people or systems use, an attacker with a leaked key can use that very proxy. The allowlist is then only as tight as the group allowed through the proxy. Anthropic points out this risk in its documentation itself.

The Claude IP allowlist as the only layer of protection. It limits where a key works, but not for how long. Keep rotating keys regularly, and check whether Workload Identity Federation is an option for your environment. I described how to cap consumption per agent as well in Token budget for AI agents.

FAQ

Does the Claude IP allowlist apply to Amazon Bedrock?

No. According to Anthropic, Claude in Amazon Bedrock and Claude Platform on AWS are explicitly not covered. There, you control access via IAM policies, for example with conditions on the VPC endpoint or source IP.

Which error do I get if my IP is not allowed?

The API responds with HTTP 403 and the error type permission_error. In the TypeScript SDK, you catch it as Anthropic.PermissionDeniedError and additionally check the type field.

Can I add private IP ranges?

No, only public addresses make sense, because the API only sees the address after the NAT or proxy. Ranges may also be at most /8 (IPv4) or /32 (IPv6) wide, and 1,000 entries are possible in total.

How long does it take for a change to apply?

Up to 15 minutes on the Claude Platform. So do not schedule changes right before a release, and test every caller specifically afterwards.

Your next step

Before you switch anything on: do the inventory from the rollout section. Write down which systems call the Claude API with your organization’s keys and from which address. This list alone shows you how big the change really is. If you are working on the SDK anyway, a look at the migration to Claude API 2026-10-21 is worth it too.

Over to you: would you introduce a NAT gateway just for Claude calls to use the Claude IP allowlist, or would you rather rely on short key lifetimes and Bedrock with IAM?

Sources

This post was created with AI assistance.

πŸ‘‰ Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser.

πŸ“¬ Stay updated: New articles on serverless, AI and leading tech teams via RSS, browser push or the weekly newsletter (in German).

Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.