Photo by Ahmad Firoz on Unsplash
To find public Lambda function URLs, list functions with ListFunctions, call ListFunctionUrlConfigs for each one, and flag URLs whose AuthType is NONE. Then read the function’s resource-based policy with GetPolicy: a URL is public only if that policy also allows lambda:InvokeFunctionUrl for the principal *. The script below checks both.
A Lambda function URL gives a function its own HTTPS endpoint with no API Gateway in front. That’s convenient for webhooks and quick prototypes, and it’s also how a function ends up callable by anyone who finds the address. This example is for engineers who need to find public Lambda function URLs across an account, see why each one is reachable, and decide which should stay that way.
You’ll get a read-only TypeScript script for the AWS SDK for JavaScript v3 that scans every enabled Region. It’s one of our AWS SDK v3 practical examples, and it pairs with the script to find Lambda functions on deprecated runtimes: an old runtime behind a public URL is the worst combination.
What makes a Lambda function URL public?
Two settings decide it, and the Lambda guide to controlling access to function URLs describes both:
AuthType. WithAWS_IAM, callers must sign requests with IAM credentials that havelambda:InvokeFunctionUrlandlambda:InvokeFunction. WithNONE, Lambda doesn’t authenticate the request at all.- The resource-based policy. Even with
NONE, the function’s policy must grant public access, or callers get403 Forbidden. The console and AWS SAM add that policy automatically when you create aNONEURL; the CLI, CloudFormation and the API don’t.
Since October 2025, new function URLs need both lambda:InvokeFunctionUrl and lambda:InvokeFunction in that policy. The default public policy pairs lambda:InvokeFunctionUrl with the condition lambda:FunctionUrlAuthType = NONE, and lambda:InvokeFunction with lambda:InvokedViaFunctionUrl = true, so the second grant only works through the URL.
The script treats a statement as public when it allows the principal * and has no condition beyond those two URL keys. A statement limited by aws:SourceAccount or aws:PrincipalOrgID isn’t counted as public.
What does the script do?
- Lists Regions
DescribeRegionsreturns the Regions enabled for your account, or you pass--regions=. - Lists functions and their URLs
paginateListFunctions, thenpaginateListFunctionUrlConfigsper function. A URL can sit on the unqualified function or on an alias. - Reads the resource-based policy
GetPolicywith the alias asQualifierwhen the URL belongs to an alias. A missing policy (ResourceNotFoundException) means no public grant. - Checks CORSFlags
Cors.AllowOriginscontaining*. - Prints a verdict per URLPublic,
NONEbut blocked, or IAM auth. It never changes anything.
Prerequisites
- Node.js 18 or later, npm and
tsx. - The
@aws-sdk/client-lambdaand@aws-sdk/client-ec2packages (EC2 only lists Regions). - A profile for the account you’re auditing. Function URLs are Regional, so the scan covers every enabled Region by default.
Which IAM permissions does it need?
Four read actions. ListFunctions and DescribeRegions need *; the other two can be limited to function ARNs in your account (replace 123456789012). The trailing * also matches alias-qualified ARNs.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListFunctionsAndRegions",
"Effect": "Allow",
"Action": ["lambda:ListFunctions", "ec2:DescribeRegions"],
"Resource": "*"
},
{
"Sid": "ReadUrlConfigsAndPolicies",
"Effect": "Allow",
"Action": ["lambda:ListFunctionUrlConfigs", "lambda:GetPolicy"],
"Resource": "arn:aws:lambda:*:123456789012:function:*"
}
]
}
To check a policy like this against your own code, the IAM policy generator for TypeScript AWS SDK code reads the script and lists the actions it calls.
The full script to find public Lambda function URLs
// find-public-lambda-function-urls.ts
// Lists every Lambda function URL in every enabled Region, its AuthType, whether the function's
// resource-based policy lets anyone ("*") invoke it, and whether CORS allows any origin.
// Read-only: it never changes a function, URL or policy.
// Usage: npx tsx find-public-lambda-function-urls.ts [--regions=us-east-1,eu-west-1]
import { EC2Client, DescribeRegionsCommand } from "@aws-sdk/client-ec2";
import {
LambdaClient,
GetPolicyCommand,
ResourceNotFoundException,
paginateListFunctions,
paginateListFunctionUrlConfigs,
type FunctionUrlConfig,
} from "@aws-sdk/client-lambda";
const regionArg = process.argv.find((a) => a.startsWith("--regions="))?.split("=")[1];
interface Statement { Effect?: string; Principal?: unknown; Action?: string | string[]; Condition?: Record<string, Record<string, unknown>> }
interface Row { Region: string; Function: string; Qualifier: string; AuthType: string; PublicPolicy: string; CorsOrigins: string; Verdict: string }
// Condition keys that only describe the function URL; anything else (aws:SourceAccount, aws:PrincipalOrgID ...) narrows access.
const URL_ONLY_KEYS = new Set(["lambda:functionurlauthtype", "lambda:invokedviafunctionurl"]);
function isEveryone(principal: unknown): boolean {
if (principal === "*") return true;
if (principal && typeof principal === "object" && "AWS" in principal) {
const aws = (principal as { AWS: unknown }).AWS;
return aws === "*" || (Array.isArray(aws) && aws.includes("*"));
}
return false;
}
function allows(stmt: Statement, action: string): boolean {
const actions = Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action ?? ""];
return actions.some((a) => a === "*" || a === "lambda:*" || a.toLowerCase() === action.toLowerCase());
}
function unrestricted(stmt: Statement): boolean {
const entries = Object.values(stmt.Condition ?? {}).flatMap((block) => Object.entries(block));
return entries.every(([key, value]) => {
const k = key.toLowerCase();
if (k === "lambda:functionurlauthtype") return String(value).toUpperCase() === "NONE"; // an AWS_IAM-only grant isn't public
return URL_ONLY_KEYS.has(k);
});
}
async function publicActions(lambda: LambdaClient, fn: string, qualifier?: string): Promise<string[]> {
try {
const res = await lambda.send(new GetPolicyCommand({ FunctionName: fn, Qualifier: qualifier }));
const doc = JSON.parse(res.Policy ?? "{}") as { Statement?: Statement[] };
const open = (doc.Statement ?? []).filter((s) => s.Effect === "Allow" && isEveryone(s.Principal) && unrestricted(s));
return ["lambda:InvokeFunctionUrl", "lambda:InvokeFunction"].filter((a) => open.some((s) => allows(s, a)));
} catch (err) {
if (err instanceof ResourceNotFoundException) return []; // no resource-based policy at all
throw err;
}
}
function verdict(url: FunctionUrlConfig, open: string[]): string {
if (url.AuthType === "AWS_IAM") return "IAM auth (signed requests only)";
if (open.includes("lambda:InvokeFunctionUrl")) return "PUBLIC: anyone with the URL can invoke";
return "AuthType NONE but no public grant (403)";
}
async function scanRegion(region: string): Promise<Row[]> {
const lambda = new LambdaClient({ region });
const rows: Row[] = [];
for await (const page of paginateListFunctions({ client: lambda }, {})) {
for (const fn of page.Functions ?? []) {
const name = fn.FunctionName ?? "";
for await (const urls of paginateListFunctionUrlConfigs({ client: lambda }, { FunctionName: name })) {
for (const url of urls.FunctionUrlConfigs ?? []) {
// A URL on an alias has a qualified ARN: arn:aws:lambda:region:account:function:name:alias
const parts = (url.FunctionArn ?? "").split(":");
const qualifier = parts.length > 7 ? parts[7] : undefined;
const open = await publicActions(lambda, name, qualifier);
const origins = url.Cors?.AllowOrigins ?? [];
rows.push({
Region: region,
Function: name,
Qualifier: qualifier ?? "$LATEST",
AuthType: url.AuthType ?? "",
PublicPolicy: open.length ? open.join(" + ") : "none",
CorsOrigins: origins.includes("*") ? "* (any site)" : origins.join(", ") || "not set",
Verdict: verdict(url, open),
});
}
}
}
}
return rows;
}
async function main(): Promise<void> {
const regions = regionArg
? regionArg.split(",")
: ((await new EC2Client({}).send(new DescribeRegionsCommand({}))).Regions ?? []).map((r) => r.RegionName ?? "").filter(Boolean);
const rows: Row[] = [];
for (const region of regions.sort()) {
try {
rows.push(...(await scanRegion(region)));
} catch (err) {
console.error(`${region}: skipped (${err instanceof Error ? err.name + ": " + err.message : String(err)})`);
}
}
console.table(rows);
const pub = rows.filter((r) => r.Verdict.startsWith("PUBLIC"));
console.log(`${rows.length} function URLs in ${regions.length} Regions; ${pub.length} public.`);
console.log("Read-only: nothing was changed.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-lambda @aws-sdk/client-ec2
npm install --save-dev tsx typescript
# Every enabled Region
AWS_PROFILE=readonly npx tsx find-public-lambda-function-urls.ts
# Only two Regions
AWS_PROFILE=readonly npx tsx find-public-lambda-function-urls.ts --regions=us-east-1,eu-west-1
Sample output
┌─────────┬─────────────┬──────────────────┬───────────┬───────────┬────────────────────────────────────────────────────┬───────────────────────────┬───────────────────────────────────────────┐
│ (index) │ Region │ Function │ Qualifier │ AuthType │ PublicPolicy │ CorsOrigins │ Verdict │
├─────────┼─────────────┼──────────────────┼───────────┼───────────┼────────────────────────────────────────────────────┼───────────────────────────┼───────────────────────────────────────────┤
│ 0 │ 'eu-west-1' │ 'stripe-webhook' │ '$LATEST' │ 'NONE' │ 'lambda:InvokeFunctionUrl + lambda:InvokeFunction' │ 'not set' │ 'PUBLIC: anyone with the URL can invoke' │
│ 1 │ 'us-east-1' │ 'report-export' │ 'prod' │ 'NONE' │ 'lambda:InvokeFunctionUrl' │ '* (any site)' │ 'PUBLIC: anyone with the URL can invoke' │
│ 2 │ 'us-east-1' │ 'demo-hello' │ '$LATEST' │ 'NONE' │ 'none' │ 'not set' │ 'AuthType NONE but no public grant (403)' │
│ 3 │ 'us-east-1' │ 'internal-api' │ '$LATEST' │ 'AWS_IAM' │ 'none' │ 'https://app.example.com' │ 'IAM auth (signed requests only)' │
└─────────┴─────────────┴──────────────────┴───────────┴───────────┴────────────────────────────────────────────────────┴───────────────────────────┴───────────────────────────────────────────┘
4 function URLs in 17 Regions; 2 public.
Read-only: nothing was changed.
Names are illustrative. stripe-webhook is public by design: webhook senders can’t sign AWS requests, so the function must check the provider’s signature itself. report-export is the one to question, and its wildcard CORS setting lets any website call it from a visitor’s browser.
Is a public function URL always a problem?
No, but every one should have a reason. Webhooks and public forms need unauthenticated access, and then the function has to do the authentication: verify a signature, a token or an API key before it does any work. Everything else should move to AWS_IAM with UpdateFunctionUrlConfig, or lose its URL with DeleteFunctionUrlConfig. Functions behind API Gateway need the same review one level up: the script to find API Gateway methods without authorization lists routes anyone can call.
Two details catch people out:
- Deleting the URL leaves the policy. Lambda doesn’t remove the public statements when you delete a
NONEURL. Remove them withRemovePermission, or a URL created later with the same auth type is public again. - CORS isn’t access control. It only tells browsers which sites may read the response.
curland server-side code ignore it, as the MDN guide to cross-origin resource sharing explains. A wildcard origin matters for browser-based abuse; it doesn’t make anAWS_IAMURL public.
To stop new public URLs, an SCP can deny lambda:CreateFunctionUrlConfig and lambda:UpdateFunctionUrlConfig unless lambda:FunctionUrlAuthType is AWS_IAM. IAM Access Analyzer, which AWS provides at no charge, also reports functions whose policies grant public or cross-account access. The same exposure question applies to data, which the scripts to find public and private S3 buckets and find public EBS and RDS snapshots answer. On EC2, the matching hardening step is to find EC2 instances without IMDSv2 and require it, which closes the SSRF path to instance role credentials.
Troubleshooting
AccessDeniedExceptiononGetPolicy. The profile can list functions but not read policies. Addlambda:GetPolicyto the profile’s policy.TooManyRequestsExceptionin large accounts. The script makes two calls per function. SDK v3 retries throttled calls; you can raise the retry count as shown in the guide to configure retry and timeout settings in AWS SDK for JavaScript v3.- A URL you know about is missing. Check the Region list, and whether the URL is on an alias of a function in another account.
- A public URL returns
403. The policy grantslambda:InvokeFunctionUrlbut notlambda:InvokeFunction, which URLs created since October 2025 need.
Ask ChatWithCloud instead
You can ask ChatWithCloud “Which Lambda functions in us-east-1 have a function URL with auth type NONE?” It writes AWS SDK for JavaScript v2 code, runs it locally with your AWS profile and summarizes the result, much like the questions in the guide to ask AI about Lambda errors in your AWS account. It covers one profile and Region per session, and it runs generated code without a confirmation step, so connect ChatWithCloud to a read-only AWS profile before you ask. The ChatWithCloud security page lists what’s sent to the model.
Frequently asked questions
Is a Lambda function URL with AuthType NONE public?
Only if the function’s resource-based policy also allows lambda:InvokeFunctionUrl for everyone. Without that grant, requests get 403 Forbidden even with NONE.
How do I make a Lambda function URL private?
Change its AuthType to AWS_IAM with UpdateFunctionUrlConfig and remove the public policy statements with RemovePermission. Callers then need signed requests.
Can a function have more than one URL?
Yes. A URL can be attached to the unqualified function or to an alias, so check each alias. The script reads each URL’s policy with the matching qualifier.
Does CORS protect a Lambda function URL?
No. CORS controls which websites a browser lets read the response. Anyone calling the URL directly isn’t affected by it.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud