
A Lambda URL or a function URL is a public HTTP endpoint unless you attach a real authorizer.
Serverless did not remove authentication. It moved it to IAM, to an API Gateway authorizer, or to a check you wrote in the handler. If none of those run, the function is on the internet.
The usual mistake is ‘it is inside AWS, so it is private’ while the function URL auth type is NONE and the resource policy allows *.
This page is the access controls AWS actually documents, the misses that keep showing up, and the test that an unauthenticated POST does not run your code.
AWS’s Control access to Lambda function URLs page on 22 August 2026. The note at the top still says that starting in October 2025, new function URLs need both lambda:InvokeFunctionUrl and lambda:InvokeFunction. The same page says AuthType NONE plus a public resource policy means anyone who has the URL can run the code.
The platform patches the runtime. It does not patch your resource policy, your shared role, or an S3 object key you interpolate. Pair this catalog with that sibling, with the injection guide for every interpreter inside the handler, and with JWT in Express when the invoke carries a bearer you must verify. Object rules still sit on the IDOR guide.
Function URL auth after October 2025
Two AuthType values exist: AWS_IAM and NONE. AWS_IAM checks the caller with SigV4. NONE does not. The docs are blunt: when NONE is set and the resource policy grants public invoke, any unauthenticated user with the URL can invoke the function. I am quoting that page, not a third-party blog.
New URLs from October 2025 need both actions. Older URLs may still be on the single-action shape. Serverless Framework issue 13147 quotes an AWS mail that asked customers to align policies by 1 November 2026 and noted a temporary exception for accounts that already had a URL. that issue. If your template still grants only InvokeFunctionUrl, add InvokeFunction on purpose. Do not wait for a 403 in production to teach you the second action.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "UrlIamOnly",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/invoiceWorker"
},
"Action": "lambda:InvokeFunctionUrl",
"Resource": "arn:aws:lambda:us-east-1:123456789012:function:invoiceFn",
"Condition": {
"StringEquals": {
"lambda:FunctionUrlAuthType": "AWS_IAM"
}
}
},
{
"Sid": "UrlInvokeOnly",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/invoiceWorker"
},
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:us-east-1:123456789012:function:invoiceFn",
"Condition": {
"Bool": {
"lambda:InvokedViaFunctionUrl": "true"
}
}
}
]
}
Identifiers stay invoiceFn, invoiceWorker, and invoiceRole. The condition on the second statement stops a principal who may invoke through the URL from also calling the SDK path unless you meant that. For a browser app, prefer API Gateway or CloudFront plus an authorizer. Raw NONE is for a webhook whose first line is an HMAC you can name. If you cannot name that check, do not use NONE.
Deleting a NONE URL does not delete the public resource policy. The docs say you must remove that statement yourself. A leftover Principal: "*" is still an invoke grant after the pretty URL is gone.
Over-broad IAM is the blast radius
The execution role is the subject for every AWS API the function calls. One noisy dependency, one injectable key, one debug line that prints the env: the role is what that process can do next. AdministratorAccess on a resize worker is how an object name becomes an account problem. AmazonS3FullAccess is the same class of miss.
Mint invoiceRole for invoiceFn. Read one prefix. Write one prefix. Put logs on one group. Decrypt only via Secrets Manager if you fetch a secret. The sibling page writes the full policy. This page only names the failure: a shared role, a star action, a star resource.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadInvoiceIn",
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::invoice-in-prod/inbox/*"
},
{
"Sid": "WriteInvoiceOut",
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": "arn:aws:s3:::invoice-out-prod/render/*"
},
{
"Sid": "OwnLogs",
"Effect": "Allow",
"Action": ["logs:CreateLogStream", "logs:PutLogEvents"],
"Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/lambda/invoiceFn:*"
}
]
}
Event source mappings need their own narrow grant: sqs:ReceiveMessage on one queue, or dynamodb:GetRecords on one stream. Do not attach the managed full-access policy so the worker can poll. IAM Access Analyzer is the first-party report for public and unused access.
Resource-based policy on the function is the other half. lambda:AddPermission with Principal: "*" or an account you do not recognize is an invoke hatch. Review that statement in the same PR as the handler.
Event fields are just input
A function is invoked with a JSON event. API Gateway, a function URL, S3, SQS, EventBridge, and IoT all fill different shapes. None of those shapes are trusted input. The control is the same as any other interpreter: parse, then use a typed API, never a shell string.
S3 object keys are chosen by whoever can write the bucket. SQS bodies are chosen by whoever can send to the queue. Query strings are chosen by the caller. If invoiceFn builds aws s3 cp or convert with those strings, you have a command-injection hatch. The injection guide is the longer form. Here the sink is subprocess or child_process inside a 128 MB environment whose role is the prize.
SQL, HTML, and path joins fail the same way. A key that contains ../ is a traversal if you join it onto /tmp. A key that contains a quote is SQL if you concatenate. Defense is InvoiceEvent.parse, GetObject through the SDK, and a minted local name.
The handler that refuses a bad key
Parse first. Authorize second. Touch AWS third. Identifiers stay invoiceFn, INVOICE_IN, storedName.
import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { randomUUID } from "node:crypto";
import { z } from "zod";
const s3 = new S3Client({});
const INVOICE_IN = "invoice-in-prod";
const INVOICE_OUT = "invoice-out-prod";
const S3Record = z.object({
s3: z.object({
bucket: z.object({ name: z.literal(INVOICE_IN) }),
object: z.object({
key: z.string().regex(/^[A-Za-z0-9/_-]{1,128}$/),
}),
}),
});
const InvoiceEvent = z.object({
Records: z.array(S3Record).min(1).max(1),
});
function requireCaller(event) {
const iam = event.requestContext && event.requestContext.identity;
if (iam && typeof iam.userArn === "string") {
return { userId: iam.userArn, via: "iam" };
}
if (event.Records) return { userId: "s3-event", via: "s3" };
return null;
}
export async function handler(event) {
const actor = requireCaller(event);
if (!actor) return { statusCode: 401, body: "Unauthorized" };
const parsed = InvoiceEvent.safeParse(event);
if (!parsed.success) return { statusCode: 400, body: "Bad event" };
const key = parsed.data.Records[0].s3.object.key;
const storedName = `${randomUUID()}.pdf`;
const obj = await s3.send(new GetObjectCommand({
Bucket: INVOICE_IN,
Key: key,
}));
const bytes = await obj.Body.transformToByteArray();
await s3.send(new PutObjectCommand({
Bucket: INVOICE_OUT,
Key: `render/${storedName}`,
Body: bytes,
}));
return { statusCode: 204 };
}
No exec, no template string into a CLI, no use of the raw key as a local path. The regex is an allowlist, not a filter of metacharacters you remembered. If the event came from a URL, requireCaller must see IAM or a JWT authorizer. Missing caller is 401. The fallback is that early return, not a later hope that S3 will 403.
CALLER SigV4 | S3 event | JWT authorizer | v GATE AuthType AWS_IAM resource policy names invoiceWorker NONE + Principal * -> public invoke | v PARSE InvoiceEvent.safeParse key allowlist miss -> 400 | v ROLE invoiceRole GetObject inbox/* only PutObject render/* only no shell, no star actions
Triggers that should stay private
An S3 trigger on a bucket you do not control is an invoke you did not mean. A queue other accounts can send to is the same. An EventBridge rule with a star principal is the same. Each of those is a hatch. Put the bucket policy, the queue policy, and the function resource policy in the same review as handler.
| Failure | What it looks like | Fix |
|---|---|---|
| Public URL | AuthType NONE, Principal * | AWS_IAM or a named HMAC first |
| Stale URL policy | URL deleted, public statement left | Remove the statement |
| Shared role | Five functions, one star policy | invoiceRole on invoiceFn |
| Event to shell | Key interpolated into exec | SDK plus allowlist |
| Foreign trigger | Bucket or queue you do not own | Resource policy allowlist |
Reserved concurrency is a bill cap, not authz. Set it on invoiceFn so a public miss cannot take the account pool. CloudWatch logs will hold the event if you console.log(event). That log is readable to a wider set of roles than the function. Do not print keys, tokens, or bodies.
Prove invoiceFn yourself
You are proving your function in your account.
aws lambda get-function-url-config --function-name invoiceFn. ExpectAuthTypeAWS_IAMunless you can name the HMAC line.aws lambda get-policy --function-name invoiceFn. Expect no leftoverPrincipal: "*"from a deleted URL.- Read
invoiceRole. Expect prefix-scoped S3 and no"*"actions. - Invoke with no signature. Expect 403 from IAM, or 401 from
requireCaller. - Grep the bundle for
exec,spawn,AuthType: NONE, andconsole.log(event).
aws lambda get-function-url-config --function-name invoiceFn \
--query 'AuthType'
aws lambda get-policy --function-name invoiceFn
rg -n "exec\\(|spawn\\(|AuthType:\\s*NONE|console\\.log\\(event\\)|AmazonS3FullAccess" \
--glob '!node_modules'
A 200 on an unsigned URL is the finding. A star in the role is the finding. A raw key in a shell string is the finding. The rest is the sibling page: secrets in the manager, one role, authz on the object.
Questions we keep getting
Is AuthType NONE ever acceptable?
Yes, for a public webhook whose first instruction is a signature check you can point at in the file. The resource policy will still be public. Prefer API Gateway if you want throttling and a named authorizer in front. Invoice reads are not that case.
Can several functions share invoiceRole if they share a bucket?
They can, and then the least careful function inherits the same GetObject. Split the role. Shared code in a monorepo is the eranation point. Shared privilege is not.
Does an allowlist on the key replace canInvoice?
No. The allowlist stops the key from becoming a command or a path. canInvoice stops this caller from touching that row. Keep both when the event names an object id.



