Executive summary
AWS Lambda functions now support full IAM resource-based policies. You can hand Lambda a complete JSON policy document through the new PutResourcePolicy API, the console's JSON editor, the CLI, or infrastructure as code β with multiple principals, multiple actions, explicit Deny statements, and the complete range of IAM global condition keys. It is available in all AWS commercial Regions at no additional charge.
The previous state is worth stating plainly, because it explains why so many Lambda estates enforce their invocation rules somewhere other than on the function. AWS's own wording: before this update, "Lambda functions required customers to add permissions individually per principal. This provided limited flexibility." Every statement was an Allow. One principal per call. And only three condition keys were available on that path β aws:SourceArn, aws:SourceAccount and aws:PrincipalOrgID.
Three things the announcement does not say, each of which changes how you should roll this out:
PutResourcePolicyreplaces the entire policy. Not merges β replaces. Every statement that S3, EventBridge, SNS or API Gateway added when you configured a trigger is gone in the same call, silently.- The policy budget is 20 KB for the whole document, which is 20,480 bytes across every statement. Consolidating ten
AddPermissionstatements into one expressive policy usually shrinks it; writing per-account statements for an estate does not. - You get optimistic concurrency, but only if you ask for it.
RevisionIdis an optional parameter. Omit it and a concurrent change is overwritten without complaint; pass it and a stale write fails with HTTP 412.
The capability is genuinely good and the migration is genuinely sharp. Both halves matter.
What changed
The developer guide now describes two distinct methods of putting permissions on a function, and is explicit about which one it prefers:
"Full JSON policy β Use the Lambda console, AWS CLI, or the PutResourcePolicy API action to add a complete JSON policy document. With a full JSON policy, you can use the complete range of IAM global condition keys, add multiple statements with multiple principals, and create explicit
Denystatements. The maximum size for a JSON resource-based policy is 20 KB.""Individual permissions β Use the console or AddPermission API action to add single
Allowstatements. Individual permissions support only a limited set of condition keys (aws:SourceArn,aws:SourceAccount, andaws:PrincipalOrgID)."
Three new API actions arrive with it β PutResourcePolicy, GetResourcePolicy and DeleteResourcePolicy β alongside the existing AddPermission, RemovePermission and GetPolicy, which all continue to work. AWS is explicit that nothing breaks: "Existing resource-based policies created with AddPermission continue to work without modification. No changes to your function code are required to use the new PutResourcePolicy API."
One detail in the permissions table is easy to skim past and will fail a deployment. PutResourcePolicy requires three IAM permissions, not one: lambda:PutResourcePolicy, lambda:AddPermission and lambda:RemovePermission. That is the API telling you what it does β it removes as much as it adds.
The scope is also wider than invocation, which is the part most coverage will miss. The list of Lambda API actions that respect a resource-based policy includes UpdateFunctionCode, PublishVersion, CreateAlias, UpdateAlias, PutProvisionedConcurrencyConfig, TagResource and DeleteFunction. A resource policy can therefore delegate deployment to another account, not merely invocation β and until now that was awkward enough that most teams built a cross-account role instead.
Architecture
Nothing moves on a network diagram. What moves is where an invocation rule is allowed to live.
Under AddPermission, a function's policy could say who may call it and, weakly, from which source. It could not say who may not. So every rule of the form "anyone in the org except the sandbox accounts", "only from inside the VPC endpoint", "only roles tagged for this workload" had to be enforced somewhere with a richer vocabulary: the caller's identity policy, an SCP, or a proxy in front of the function.
Each of those has the same structural weakness. They are enforced somewhere the function does not control. An identity policy belongs to the caller's account, which in a large estate is a team you have never met; an SCP belongs to the organisation's management account and covers principals rather than resources; a proxy is another thing to run. The rule is real, but the function has no way to insist on it, and no way to know whether it is still there.
A full resource policy moves the rule onto the resource, and IAM's cross-account evaluation is what makes that meaningful. AWS states it directly: "For cross-account requests, the requester in the trusted AccountA must have an identity-based policy... Additionally, the resource-based policy in AccountB must allow the requester in AccountA." Both accounts are evaluated, and "the request is allowed only if both evaluations return a decision of Allow."
The consequence is the architectural point of the whole release: a Deny on the function is the only control that reaches callers whose identity policies you do not own. You cannot read the other account's policies. You cannot audit them. You cannot stop them being widened next Tuesday. What you can do now is state the boundary on your own resource, where it is evaluated on every call and it is yours to change.
The second shift is smaller and more practical. A function policy is now a single document, which means it can be diffed, reviewed in a pull request, and asserted against in a test. A pile of appended statements could only be read back and inspected. That is the difference between a policy and a collection of permissions, and it is why the console gained a JSON editor rather than another dialog box.
The call that erases every trigger
This is the part to internalise before the first call. The guide flags it as Important, and the API reference repeats it:
"Using
put-resource-policyreplaces any existing resource-based policy on the resource. If the resource already has permissions defined withadd-permission,put-resource-policyoverwrites them."
Now consider what is actually on a mature function's policy. Almost none of it was written by a person:
- An
Allowfors3.amazonaws.com, written when someone configured a bucket notification. - An
Allowforevents.amazonaws.comper EventBridge rule. - An
Allowforapigateway.amazonaws.com, one per stage, added by the API Gateway console. - An
Allowforsns.amazonaws.comper subscribed topic. - Whatever a colleague added by hand eighteen months ago and did not write down.
Every one of those arrived through AddPermission, appended without ceremony, and none of them is in your CloudFormation template β because the service that created the trigger created the permission too. A single put-resource-policy call replaces all of them with whatever document you sent. There is no merge, no confirmation, no warning, and no error. The function keeps running. The triggers stop firing, and the first signal is a queue that stops draining or a report that does not arrive.
The safe sequence is short and non-negotiable:
get-resource-policyto retrieve the current document and itsRevisionId.- Merge your statements into what is already there, rather than composing from scratch.
put-resource-policywith--revision-id, so a concurrent change fails loudly with HTTP 412PreconditionFailedExceptioninstead of being silently discarded.
Note that RevisionId is optional. That is a deliberate API design β it keeps the simple case simple β and it means the unsafe call is the shorter one to type. AWS's guidance is unambiguous: "If you provide an older revision ID, Lambda does not update your function's policy."
There is one piece of good news in the ordering rules. AddPermission still appends, even after a full policy is in place: "if you call add-permission after put-resource-policy, the new statement appends to the existing JSON policy." So adding a trigger later does not undo your document. Only the reverse direction destroys.
What "full" actually buys you
Four capabilities that were previously unavailable on a Lambda function, and what each one replaces.
| Capability | What you can now write | What it replaces |
|---|---|---|
Explicit Deny |
Allow a service broadly, then deny one specific source. AWS's own example allows every S3 bucket in an account to invoke a function and denies exactly one bucket. | Enumerating every permitted source ARN in a separate AddPermission call, and remembering to add the next one. |
| Organisation conditions | aws:PrincipalOrgID and aws:PrincipalOrgPaths β scope invocation to the organisation, or to one OU subtree, "without enumerating individual accounts". |
A statement per consuming account, updated by hand whenever an account is created or closed. |
| The full condition key range | aws:SourceIp, aws:SourceVpce, aws:PrincipalTag, aws:SecureTransport, and the rest. ABAC on the invoke path becomes possible. |
Enforcement in the caller's identity policy, in an account you do not control. |
| Multiple principals per statement | Several roles or services in one statement, several actions in one statement. The announcement's own framing: allow multiple services to invoke a function using a single policy. | One API call per principal, and a policy whose length grew linearly with the number of consumers. |
The Deny capability is the one worth thinking hardest about, because it composes differently from the others. An Allow is a statement about who may call. A Deny is a statement that survives every other policy in both accounts β it cannot be overridden by an identity policy, a permissions boundary, or a later Allow. On a function that is invoked from accounts you do not administer, that asymmetry is the entire value.
Business value
The direct saving is small and the indirect one is not. Nobody was paying for AddPermission, and nobody is paying for PutResourcePolicy. What changes is the cost of a class of work that most platform teams do continuously and quietly.
- Onboarding a consuming account stops touching the function. With
aws:PrincipalOrgIDor an OU path, an account created this morning can call the function this afternoon without a change request against the producing team. That is a queue removed, not a task made faster. - The policy becomes reviewable. A single document belongs in a pull request, and a reviewer can see the whole boundary at once. A set of appended statements could only be audited after the fact, by reading back what was there.
- Regulatory requirements for explicit denial become expressible. AWS names this outcome directly in the guide's security best practices β "you can also meet regulatory requirements for explicit denials". Teams that previously had to answer that requirement with an SCP, and argue about scope, can now answer it on the resource.
- Cross-account deployment delegation gets simpler. Because
UpdateFunctionCodeandPublishVersionrespect the resource policy, a shared deployment account can be granted deployment rights on a function directly, instead of via a role that must be assumed and maintained.
Security considerations
Public access is refused, not warned about. PutResourcePolicy returns PublicPolicyException when "the resource-based policy you tried to add to the Lambda resource would grant public access to it, which isn't allowed". This is a guardrail worth knowing exists before you write "Principal": "*" with an org condition and wonder why it is rejected or accepted β the condition is what makes the difference between a policy that is scoped and one that is public.
A resource Deny is the strongest thing you own on this function. Because both accounts are evaluated on a cross-account call, and an explicit Deny in either evaluation ends the request, a Deny written here holds regardless of what the calling account does with its own policies. Use it for the boundaries that must not be negotiable: no invocation from outside the organisation, no invocation from outside the VPC endpoint, no invocation over anything but TLS.
Blast radius runs the other way too. The same call that can express your security boundary can delete the five statements that make the application work. Treat lambda:PutResourcePolicy as a privileged permission, not an ordinary deployment one β and note that granting it obliges you to grant lambda:RemovePermission alongside it, which is a reasonable prompt to ask who should hold it.
Wildcards are not accepted in the target. The ResourceArn "must be a complete ARN, and the operation does not accept wildcard characters", and it may be qualified or unqualified. So there is no way to apply one policy across a set of functions in a single call. Policy uniformity across a fleet is something your tooling has to provide; Lambda will not.
Version and alias scope still matters. A policy attached to an unqualified ARN and one attached to an alias are different policies on different resources. Migrating a function that publishes versions means deciding, per resource, which document applies β and it is an easy place to leave an alias governed by an old set of appended permissions while the unqualified function carries the new document.
Cost considerations
There is no charge. Full IAM resource-based policies are "available in all AWS commerical Regions at no additional charge", and the API actions are control plane calls like any other.
The budget that does bind is throughput, and it is the one that will surprise anyone scripting a fleet-wide migration. Lambda's control plane rate limits are not adjustable:
| Quota | Value | Adjustable |
|---|---|---|
| Function resource-based policy size | 20 KB β 20,480 bytes for the whole document | No |
GetPolicy API requests | 15 requests per second | Cannot be increased |
| Remainder of control plane API requests | 15 requests per second across all APIs, not per API | Cannot be increased |
Read the third row carefully. The 15 requests per second is shared across the whole control plane, so a migration script doing get-resource-policy then put-resource-policy per function is spending two calls from a budget that your deployments, your tagging jobs and your inventory tooling are also drawing on. A few thousand functions is a long afternoon with backoff, not a five-minute loop β and running it flat out will throttle deployments happening at the same time.
Operational considerations
Your IaC still speaks the old dialect. AWS::Lambda::Permission in CloudFormation, and the equivalents in SAM and CDK, emit AddPermission calls β one statement each. That is not deprecated and continues to append. But it means a stack that manages permissions this way and a policy document managed elsewhere are two writers on the same object, and only one of them replaces. Decide which owns the policy, per function, and write it down.
Drift detection will not see this. A statement appended by a service when a trigger was created is invisible to the template that created neither. That was survivable when everything appended; it is a hazard now that one path replaces.
RevisionId is the only concurrency control, and it is opt-in. Fixed length 36, retrieved from get-resource-policy, returned again on success so you can chain updates. Any automation that writes function policies should pass it every time β the failure mode without it is not an error, it is a lost change.
Read-back has two APIs and they are not equivalent. GetResourcePolicy returns the document with its revision; the older GetPolicy also works. Use the former in anything that intends to write afterwards, because it is the one that hands you the value you need to write safely.
Tradeoffs
| Choice | For | Against |
|---|---|---|
| Move to a full JSON policy now | One reviewable document; Deny available; org conditions remove per-account maintenance; the console, CLI and IaC all edit the same thing. |
You must first discover every statement services added on your behalf, and any miss is a trigger that stops working with no error. |
Stay on AddPermission |
Nothing breaks, nothing to migrate, and it remains fully supported. Appending is safe by construction. | No Deny, three condition keys, one principal per call β so the interesting rules keep living in other people's accounts. |
Hybrid: document for humans, AddPermission for triggers |
Matches how the permissions actually arrive. Services keep appending; your deliberate rules live in a document you review. | Requires discipline about who calls put-resource-policy and when, because that call still flattens the appended half. |
| Keep enforcing in SCPs and identity policies | Already built, already audited, and covers services beyond Lambda uniformly. | Does not reach principals outside the organisation, and leaves the resource unable to state its own boundary. |
Implementation guidance
- Inventory before you write anything. Run
get-resource-policyacross the functions you intend to migrate and store the output. You are looking for statements whoseSidyou do not recognise β those are the ones a service wrote, and the ones a carelessputwill delete. - Start with one low-traffic function that has exactly one trigger. Migrate it, confirm the trigger still fires, and read the policy back. The failure mode here is silent, so the verification has to be behavioural, not a successful API response.
- Compose the new document from the old one. Take the retrieved policy as the base and add statements to it. Never write the document from a template and send it; that is exactly the operation that erases the triggers.
- Pass
--revision-idon every call. Wire it in at the tooling level so it cannot be forgotten under time pressure. - Add the
Denystatements last, and test them. An explicitDenyoverrides everything, including theAllowyou rely on. Verify from a principal that should be denied and one that should not. - Decide ownership per function, and encode it. Either the stack owns the policy or the document does. Two writers, one of which replaces, is a production incident waiting for a deployment window.
Best practices
- Prefer an organisation condition to an account list.
aws:PrincipalOrgIDis one statement that stays correct as the estate changes; a list of account IDs is a maintenance task disguised as a security control. - Write the boundary as a
Deny, not as the absence of anAllow. An absentAllowcan be supplied by a later change. ADenycannot be overridden. - Scope service principals with
aws:SourceAccountas well asaws:SourceArn. This has always been the right pattern for confused-deputy protection, and it is now expressible alongside everything else in one statement. - Keep the document small. The 20 KB ceiling is generous for a well-written policy and easy to hit with one statement per consumer β which is a sign the policy should be using conditions rather than enumeration.
- Treat
lambda:PutResourcePolicyas a privileged grant. It impliesAddPermissionandRemovePermission, and a single call can remove every trigger from a function. - Version the policy alongside the function. If the function has aliases that matter, decide explicitly which resource each document is attached to rather than defaulting to the unqualified ARN.
Who should adopt, who should wait
Adopt now if you run functions invoked from accounts you do not administer, if you have ever written "everyone in the org except X" and had to enumerate the org, or if a compliance requirement asked for an explicit denial and you answered it with an SCP because the function could not express one. The value here is proportional to how little you control the callers.
Adopt carefully if your functions are triggered by services rather than called directly. The capability is fine; the migration is where the risk is, and it is entirely front-loaded into the first put-resource-policy call on each function.
Wait if your functions are invoked only from within one account by principals whose identity policies you already own and review. In that case the resource policy is duplicating a control you already have, and the migration buys you a second place to get it wrong.
Key takeaways
- Lambda functions now take full IAM resource-based policies, in all AWS commercial Regions at no additional charge.
- A full JSON policy gives you explicit
Deny, multiple principals per statement, and the complete range of IAM global condition keys.AddPermissiongave youAllowonly, one principal at a time, and three condition keys. put-resource-policyreplaces the entire existing policy, including every statementAddPermissionwrote β which on a mature function is most of them, added by S3, EventBridge, SNS and API Gateway when triggers were configured.- The reverse is safe:
add-permissionafterput-resource-policyappends rather than replacing. - Pass
RevisionId. It is optional, fixed length 36, and without it a concurrent change is lost silently; with it a stale write fails with HTTP 412. - The policy ceiling is 20 KB β 20,480 bytes for the whole document, and the
Policyparameter enforces it directly. - The control plane allows 15 requests per second across all APIs, not per API, and it cannot be increased β the binding constraint on any fleet-wide migration.
- A cross-account call is evaluated in both accounts and allowed only if both return
Allow, which is what makes aDenyon the function reach callers whose identity policies you do not own. PutResourcePolicyrequireslambda:PutResourcePolicy,lambda:AddPermissionandlambda:RemovePermission, and refuses a policy that would grant public access withPublicPolicyException.- Resource policies now cover far more than invocation β
UpdateFunctionCode,PublishVersion,TagResourceandDeleteFunctionamong them β so deployment can be delegated cross-account without a role.
Official AWS references
- AWS Lambda functions now support full IAM resource-based policies β the announcement, the previous limitation, Regions and pricing
- Working with resource-based policies in Lambda β the two methods, the overwrite behaviour, required permissions,
RevisionIdguidance and the example policies - PutResourcePolicy β parameters, the 20,480-byte policy limit, and the
PreconditionFailedExceptionandPublicPolicyExceptionerrors - AddPermission β the per-principal path that still appends
- Lambda quotas β the 20 KB function resource-based policy quota and the 15 requests per second control plane rate
- Cross-account policy evaluation logic β why both accounts must return
Allow
Comments