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
EgressIpCheckFnthat 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 so no caller is surprised once the list is active
- 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.
- 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/8are no use, since the API only sees the public address. - Check the list: Run the planned list through the check script further down, together with all addresses that must be covered.
- 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 constSource:https://dev.to/sascha-ms/claude-ip-allowlist-lambda-with-a-static-egress-ip-2km8Related posts
Why Does My Malfoid AI Portrait Look Darker as a Thumbnail? Gamma-Correct Downscaling in Python, Measured
11 Free Salary Calculators: Paycheck, Take-Home Pay, Hourly & Overtime (No Signup)

PHP array_map() Function: Concepts and Practical Examples
My DSA Journey: Learning to Think Like a Programmer
SearchValues<T> in .NET 8/9 (.NET & C#)

