Serverless security: one Lambda role, secrets manager, authz

One teal lanyard badge and an extra coral badge.

Serverless security is IAM on the function, secrets that are not environment-variable leftovers, and the same object checks as any API.

Lambda will run whatever you deploy. If the role can read every bucket and the handler trusts the event body, you built a privileged script with a URL.

The usual mistake is putting a long-lived key in an environment variable because Secrets Manager felt heavy, then logging the event object that contains it.

This page is the AWS-side controls that still matter and the handler habits that leak a function faster than a bad IAM policy.

As of 22 August 2026, current AWS Lambda developer guide. The environment-variable page still says to use Secrets Manager for database credentials, API keys, and authorization tokens instead of putting those values in function configuration. lambda:GetFunctionConfiguration returns those values. That action sits on managed policies such as ViewOnlyAccess.

Pair this with JWT in Express when the invoke carries a bearer you must verify, the secure coding checklist for the handler body, and dependency hygiene for the zip you upload. The platform patches the runtime. It does not patch your IAM or your orderId check.

The invoke is one path: a named caller, a Function URL with AWS_IAM, one invoiceRole, then Secrets Manager. The env holds the ARN, not the password.

SecureCoding

One execution role, one function

Lambda always runs as an execution role. That role is the subject for every AWS API the function calls. A shared SuperLambdaRole with Action: "*" and Resource: "*" turns a single noisy dependency into an account-wide principal. Mint invoiceRole for invoiceFn and nothing else.

{
 "Version": "2012-10-17",
 "Statement": [
 {
 "Sid": "ReadOneSecret",
 "Effect": "Allow",
 "Action": ["secretsmanager:GetSecretValue"],
 "Resource": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/invoiceFn/db-*"
 },
 {
 "Sid": "DecryptThatSecret",
 "Effect": "Allow",
 "Action": ["kms:Decrypt"],
 "Resource": "arn:aws:kms:us-east-1:123456789012:key/mrk-invoice",
 "Condition": {
 "StringEquals": {
 "kms:ViaService": "secretsmanager.us-east-1.amazonaws.com"
 }
 }
 },
 {
 "Sid": "PutOwnLogs",
 "Effect": "Allow",
 "Action": [
 "logs:CreateLogStream",
 "logs:PutLogEvents"
 ],
 "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/lambda/invoiceFn:*"
 }
 ]
}

Identifiers stay invoiceFn, invoiceRole, and prod/invoiceFn/db. The secret ARN is prefix-scoped so rotation can mint a new suffix. The role cannot PutSecretValue, cannot read prod/payrollFn/*, and cannot s3:*. IAM Access Analyzer is the first-party report for unused actions.

Event source mappings need a second, equally narrow grant: sqs:ReceiveMessage on one queue, or dynamodb:GetRecords on one stream. Do not attach AmazonSQSFullAccess so the worker can poll.

The manager holds the secret, not the env

The current Lambda environment-variable chapter recommends Secrets Manager for credentials. The AWS Compute Blog is blunter: do not put those values in function configuration, because anyone who can view that configuration can see them. Datadog’s Cloud Security Atlas repeats the same permission list: lambda:GetFunction, lambda:GetFunctionConfiguration, lambda:ListFunctions.

Put the ARN in the env. Fetch the value at init. Cache it on the frozen module scope for the life of that execution environment. On a rotation miss, fetch again inside the handler. @aws-lambda-powertools/parameters 2.35.0 published on 18 August 2026. npm page for that date. getSecret is the helper.

import { getSecret } from "@aws-lambda-powertools/parameters/secrets";

const SECRET_ARN = process.env.SECRET_ARN;
let dbUrl;

async function loadDbUrl() {
 if (dbUrl) return dbUrl;
 const raw = await getSecret(SECRET_ARN, { maxAge: 300 });
 const parsed = JSON.parse(raw);
 dbUrl = parsed.dbUrl;
 return dbUrl;
}

export async function handler(event) {
 const url = await loadDbUrl();
 const actor = requireCaller(event);
 if (!actor) {
 return { statusCode: 401, body: "Unauthorized" };
 }
 const orderId = event.pathParameters && event.pathParameters.orderId;
 const order = await findOrder(url, orderId);
 if (!order || !canInvoice(actor, order)) {
 return { statusCode: 404, body: "Not found" };
 }
 return { statusCode: 200, body: JSON.stringify({ id: order.id }) };
}

Never log raw, dbUrl, or event.headers.authorization. CloudWatch is readable to a wider set of roles than you think. The Parameters and Secrets Lambda extension is the AWS-supported local cache if you do not want to manage maxAge yourself. Either path still needs GetSecretValue on that one ARN.

Authorize every invoke

The platform will run your code for anyone who is allowed to invoke it. IAM on the function is the first gate. Application authz is the second. A Cron event, an SQS record, an API Gateway HTTP API, and a function URL are four different callers. Name each one at the top of handler.

function requireCaller(event) {
 const requestContext = event.requestContext || {};
 if (requestContext.authorizer && requestContext.authorizer.jwt) {
 const claims = requestContext.authorizer.jwt.claims;
 if (typeof claims.sub === "string") {
 return { userId: claims.sub, via: "jwt" };
 }
 }
 if (event.requestContext && event.requestContext.authorizer && event.requestContext.authorizer.lambda) {
 const userId = event.requestContext.authorizer.lambda.userId;
 if (userId) return { userId, via: "lambda-auth" };
 }
 const iam = requestContext.identity;
 if (iam && iam.userArn) {
 return { userId: iam.userArn, via: "iam" };
 }
 return null;
}

function canInvoice(actor, order) {
 if (actor.via === "iam" && actor.userId.endsWith(":role/invoiceWorker")) {
 return true;
 }
 return order.ownerId === actor.userId;
}

API Gateway JWT authorizers verify iss, aud, and signature before your code runs. Still run canInvoice. A valid token for the app is not permission to invoice someone else’s orderId. That is the same object check as the IDOR guide, inside a function that lives for 100 ms.

Triggers that should never be public: object-created on a bucket you do not control, a queue other accounts can send to, an EventBridge rule with a star principal. Each of those is an invoke. Resource-based policies on the function are the allowlist. Review lambda:AddPermission in the same PR as the handler.

Function URLs after October 2025

Control access to Lambda function URLs. Two AuthType values exist: AWS_IAM and NONE. NONE plus a resource policy that grants public invoke means anyone who has the URL can run your code. The docs say that in those words.

A note on that same page: starting in October 2025, new function URLs require both lambda:InvokeFunctionUrl and lambda:InvokeFunction. Old URLs you created earlier may still be on the single-action shape. Audit both actions. Do not assume a 2024 template is complete.

For a private invoice URL, set AuthType to AWS_IAM. Grant lambda:InvokeFunctionUrl to invoiceWorker or to the user role that must call it. Add lambda:InvokedViaFunctionUrl if that principal must not also invoke through the SDK. For a browser app, prefer API Gateway or CloudFront plus an authorizer over a raw function URL. NONE is for a truly public webhook you have other ways to authenticate inside the body, HMAC on a header you check first. If you cannot name that check, do not use NONE.

An org-level service control policy that denies CreateFunctionUrlConfig unless lambda:FunctionUrlAuthType is AWS_IAM is the belt the docs already show. Copy that deny if you are in AWS Organizations. I am not inventing a 2026 breach count for public URLs. The control is the SCP.

Cloud Functions gen2 is Cloud Run IAM

Google Cloud Functions 2nd gen runs on Cloud Run. The current Cloud Run access-control page names invoke as roles/run.invoker. allUsers on that role is a public function. 1st gen used roles/cloudfunctions.invoker. A Terraform file that only binds the old role on a gen2 function can look private and still be reachable if someone later adds allUsers on the underlying service, or the reverse: you think you opened it and the Run service still requires auth. Check both.

The runtime service account is the execution identity, same job as invoiceRole. Grant roles/secretmanager.secretAccessor on one secret, not on the project. Prefer Secret Manager plus a mount or a client fetch over pasting the value into function environment configuration. Public HTTP is a product choice. A function that invoices is not that choice.

ControlLambda 2026Cloud Functions gen2
Runtime identityOne role per functionOne service account per function
SecretSecrets Manager ARN, fetch at initSecret Manager accessor on one secret
Public invokeAuthType NONE plus Principal *allUsers as run.invoker
Private HTTPAWS_IAM or API Gateway JWTRequire authentication, no allUsers
Object checkcanInvoice(actor, order)Same function, same 404

Prove your own function

You are proving invoiceFn. You are not invoking a stranger’s URL.

  1. In your account, aws lambda get-function-configuration --function-name invoiceFn. The Environment.Variables map must not contain a password. SECRET_ARN is fine.
  2. Read the attached role. Expect GetSecretValue on prod/invoiceFn/db-* and no "*" actions.
  3. If a function URL exists, expect AuthType AWS_IAM unless you can name the public HMAC check.
  4. Invoke with no token. Expect 401. Invoke with your own test token on an orderId you do not own. Expect 404.
  5. Grep the bundle for AWS_ACCESS_KEY_ID, hardcoded passwords, and console.log(event).
aws lambda get-function-configuration --function-name invoiceFn \
 --query 'Environment.Variables'

aws lambda get-policy --function-name invoiceFn

rg -n "AWS_SECRET_ACCESS_KEY|DB_PASSWORD|AuthType:\\s*NONE|allUsers" \
 --glob '!node_modules'

Reserved concurrency is the cap that stops a recursive invoke or a public URL from becoming an unbounded bill. Set it on invoiceFn. It is not authz. It is how you keep a miss from taking the account’s concurrency pool.

Questions we keep getting

Is KMS encryption of environment variables enough?

It protects the value at rest in the Lambda config store. Anyone with lambda:GetFunctionConfiguration and permission to use that key still sees the plaintext in the API response. Fetch from Secrets Manager. Leave a non-secret flag in the env if you must.

Can several functions share one role if they need the same secret?

They can, and then a bug in the least important function can read the same secret and call the same APIs. Split the role. If two functions truly share one job, they are one function with two triggers.

Does a JWT authorizer replace canInvoice?

No. The authorizer proves the token. canInvoice proves this userId may touch this orderId. Keep both. A missing authorizer is 401. A failed object check is 404.

Aphinya Dechalert

Aphinya Dechalert / About Author

Aphinya is a skilled technical writer with field experiences in software development, agile, and JavaScript full stack with AWS and Google cloud. She is a developer advocate and community builder, helping others navigate their journeys and careers as developers.