Skip to content

feat: add options to restrict ssr lambda functions to cloudfront-only access using OAC#5581

Merged
fwang merged 30 commits intoanomalyco:devfrom
MrHertal:feat/ssr-site-enable-oac
Jan 21, 2026
Merged

feat: add options to restrict ssr lambda functions to cloudfront-only access using OAC#5581
fwang merged 30 commits intoanomalyco:devfrom
MrHertal:feat/ssr-site-enable-oac

Conversation

@MrHertal
Copy link
Contributor

@MrHertal MrHertal commented Mar 19, 2025

Lambda Protection for SSR Components

This PR adds a comprehensive protection option to SSR components (Nextjs, TanStackStart, etc.) to control Lambda function URL access through CloudFront using AWS Origin Access Control.

Key Motivation

Enterprise Security Compliance: In many companies, deploying public Lambda URLs is prohibited by cybersecurity policies. Security tools like Prisma Cloud detect public Lambda URLs as security violations, preventing deployment of SSR applications.

With this PR, you can now use SST to deploy Next.js and other SSR frameworks in enterprise environments with strict security requirements!

See the AWS documentation: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-lambda.html

API Design

The protection option provides three security modes:

1. "none" (Default)

  • Lambda URLs are publicly accessible
  • Explicit public access permissions are created to ensure consistent behavior
  • Works with external webhooks and callbacks out of the box

2. "oac"

  • Lambda URLs protected by CloudFront Origin Access Control
  • Requires manual x-amz-content-sha256 header for POST requests
  • Use when you control all POST requests to your application

3. "oac-with-edge-signing"

  • Full protection with automatic header signing via Lambda@Edge
  • Works with external webhooks and callbacks seamlessly
  • Higher cost and latency but works out of the box for all scenarios

Advanced Configuration

For the "oac-with-edge-signing" mode, you can:

Use auto-created Lambda@Edge function:

new sst.aws.Nextjs("MyApp", {
  protection: "oac-with-edge-signing"
});

Customize the auto-created function:

new sst.aws.Nextjs("MyApp", {
  protection: {
    mode: "oac-with-edge-signing",
    edgeFunction: {
      memory: "256 MB",
      timeout: "10 seconds"
    }
  }
});

Use existing Lambda@Edge function:

new sst.aws.Nextjs("MyApp", {
  protection: {
    mode: "oac-with-edge-signing",
    edgeFunction: {
      arn: "arn:aws:lambda:us-east-1:123456789012:function:my-function:1"
    }
  }
});

Technical Implementation

Lambda@Edge Integration

  • Node.js 22.x runtime for optimal performance
  • Automatic SHA256 header computation for POST/PUT/PATCH requests
  • Smart request filtering to minimize processing overhead
  • Proper IAM roles with both lambda.amazonaws.com and edgelambda.amazonaws.com trust policies
  • us-east-1 deployment requirement handled automatically

Consistent Type System

  • Memory/timeout parameters use the same types as SST Function (Size, DurationSeconds)
  • ARN validation uses SST's consistent FunctionArn type and parseLambdaEdgeArn() helper
  • Error messages follow SST's VisibleError patterns

Multi-Region Support

  • All protection modes work seamlessly with multi-region deployments
  • Permissions are created for every server function in every region
  • Lambda@Edge functions automatically handle global distribution

Robust Permission Management

  • Explicit permissions are always created to ensure consistent behavior
  • Mode switching works reliably (no state transition issues)
  • Public access permissions include the required lambda:FunctionUrlAuthType=NONE condition
  • CloudFront permissions use proper sourceArn conditions

Example Usage

// Basic protection
const app = new sst.aws.Nextjs("MyApp", {
  protection: "oac"
});

// Full protection with Lambda@Edge
const front = new sst.aws.TanStackStart("Front", {
  protection: "oac-with-edge-signing"
});

// Multi-region with protection
const global = new sst.aws.Nextjs("GlobalApp", {
  regions: ["us-east-1", "eu-west-1", "ap-southeast-1"],
  protection: "oac-with-edge-signing"
});

Benefits

Enterprise-ready security - Meets strict cybersecurity compliance requirements
Flexible protection levels - Choose the right balance of security vs complexity
External webhook support - Lambda@Edge mode works with any external service
Multi-region ready - All modes work seamlessly across regions
Type-safe configuration - Consistent with SST's type system
Reliable state management - No issues when switching between modes

This enhancement makes SST's SSR components suitable for enterprise environments while maintaining the developer experience SST is known for.

@jayair
Copy link
Contributor

jayair commented Mar 21, 2025

Hmm that file was a WIP as a part of the v3.10 release. Would appreciate if you made these changes to just the updated ssr-site.ts

@thdxr thdxr self-assigned this Mar 22, 2025
@MrHertal MrHertal force-pushed the feat/ssr-site-enable-oac branch from d033795 to 9797838 Compare March 24, 2025 15:34
@MrHertal MrHertal changed the title feat: add possibility to enable origin access control on ssr site feat: add option to protect ssr server with origin access control Mar 24, 2025
@MrHertal
Copy link
Contributor Author

@jayair I have updated the PR

@MrHertal
Copy link
Contributor Author

@jayair @thdxr I found a better way to achieve this by using the distribution key value store

@jayair jayair requested a review from fwang March 28, 2025 21:48
@fwang
Copy link
Contributor

fwang commented Apr 2, 2025

Appreciate the PR @MrHertal!

Could u try out POST and PUT requests? According to this thread, might need to calculate and set the content hash header - #4684 (comment)

@berenddeboer
Copy link
Contributor

@MrHertal you can't use OAC, only supports GET, not POST/PUT etc etc. Mostly useless. Copy the implementation from sst v2, and use my PR anomalyco/v2#50 that fixes an import bug in the current implementation.

@fwang OAC is not good enough, as it requires clients to compute hashes so it's not transparent. Use the sst v2 method with my PR: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-lambda.html

@fwang
Copy link
Contributor

fwang commented Apr 3, 2025

@berenddeboer this comment suggests calculating the hash in CF function, does that work?

#4684

@berenddeboer
Copy link
Contributor

@berenddeboer this comment suggests calculating the hash in CF function, does that work?

#4684

ah thanks, got that now. Not sure I have a good setup to try this right now, but first question would be: any memory limits for CF functions? Probably ok in most cases, but how big can the body be?

@MrHertal
Copy link
Contributor Author

MrHertal commented Apr 3, 2025

@fwang @berenddeboer Thanks for your reply 🙂

You cannot access request body in CloudFront function, so the x-amz-content-sha256 header cannot be added automatically.

It seems doable in a Lambda@Edge, but that would introduce a lot of changes in the architecture.

The solution I propose is to offer an option to use OAC and protect lambda URL, by default it does not change anything, lambda URL will still be public. But if you enable it, it will setup the OAC and protect the URL behind CloudFront. You must then add the x-amz-content-sha256 header to each POST and PUT requests sent from your application.

The implementation depends on your application framework. Here is an example of a fetch function that does the job:

async fetchThroughOAC(
  input: string | Request | URL,
  init: RequestInit | undefined,
) {
  const body = init?.body?.toString() || "";
  const digestHex = await digestMessage(body);

  const headers = new Headers(init?.headers);
  headers.append("x-amz-content-sha256", digestHex);

  return fetch(input, { ...init, headers });
}

// See https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest
async function digestMessage(message: string) {
  const msgUint8 = new TextEncoder().encode(message);
  const hashBuffer = await crypto.subtle.digest("SHA-256", msgUint8);
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  const hashHex = hashArray
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");

  return hashHex;
}

I can add an example for TanStackStart component since I'm currently working on it. Users will need to use a similar process for NextJS or other framework.

I understand that it is a bit cumbersome, but it's a good first step for users who don't want to deploy public lambda URL.

I work in a company where cloud resources are monitored by tools like Prisma, deploying unprotected lambda URL is a red flag.

With that option, I can at least enable the protection, knowing that for POST and PUT requests, I will need to add the payload hash value of the request body in the x-amz-content-sha256 header.

@berenddeboer
Copy link
Contributor

berenddeboer commented Apr 3, 2025

@MrHertal lambda@edge is another service which looks free on the surface, but starts popping up in the bills at scale. Possibly would work, but something to take into account.

@berenddeboer
Copy link
Contributor

@MrHertal this thing:

You must then add the x-amz-content-sha256 header to each POST and PUT requests sent from your application.

That's not great, as it would require changes to applications. Imagine having to add this to all your graphql requests... Very very painful.

@MrHertal
Copy link
Contributor Author

MrHertal commented Apr 3, 2025

@berenddeboer

protectedUrl is optional, false by default. If for some reason adding the header is too much work and does not worth the benefit of having a protected URL, then people won't use it.

I think that having an option is better than no option 🙂

@vincentvanderweele
Copy link

We recently upgraded to SST v3 especially because of the AOC support. Our security team flags public lambda urls as a critical security issue due to potential resource exhaustion.

We would like to upgrade to Nextjs 15 but for that we need to upgrade to the latest SST version, which no longer supports AOC.

So we would really like to have this support. It makes sense to keep it optional because of the additional header required for POST and PUT requests.

@cgcompassion
Copy link

Question, does this solution also secure the Image Optimization lambda?

@cgcompassion
Copy link

Hi, is there a way to use this, even before it merges? Like a patch? We are stuck on SST 3.7 until this merges, because of the changes to how NextJS runs on Cloudfront... Until we have this alternate way of making lambdas private, we can't update.... Most NextJS users will not be making POST endpoints... we would use API Gateway for that... NextJS is mostly focused on GET. Since this PR is providing an option, not changing the default, it should be fairly low risk, right? I could help test it if possible?

@jayair
Copy link
Contributor

jayair commented May 23, 2025

I'll bump it up internally.

@MrHertal MrHertal changed the title feat: add origin access control option to restrict ssr lambda functions to cloudfront-only access feat: add options to restrict ssr lambda functions to cloudfront-only access with OAC Nov 16, 2025
@MrHertal MrHertal changed the title feat: add options to restrict ssr lambda functions to cloudfront-only access with OAC feat: add options to restrict ssr lambda functions to cloudfront-only access using OAC Nov 16, 2025
@MrHertal
Copy link
Contributor Author

MrHertal commented Nov 16, 2025

@edbramwell @berenddeboer

I've implemented a more complete approach. The original option has been evolved into a comprehensive protection system with three modes:

// Simple protection (my use case)
new sst.aws.Nextjs("MyApp", {
  protection: "oac"  // Manual header signing required
});

// Full protection (external webhooks/SAML)
new sst.aws.Nextjs("MyApp", {
  protection: "oac-with-edge-signing"  // Automatic Lambda@Edge signing
});

// No protection (default)
new sst.aws.Nextjs("MyApp", {
  protection: "none"  // Public access
});

Key improvements:

  • Flexible security levels - Choose the right balance for your use case
  • Lambda@Edge auto-creation - Handles the infrastructure complexity automatically
  • External webhook support - Works with SAML, payment providers, etc.
  • Multi-region ready - All modes work across regions
  • Enterprise compliance - Meets strict security requirements

The Lambda@Edge mode automatically computes SHA256 headers for POST requests, making it work seamlessly with external services while keeping the infrastructure overhead optional for those who don't need it.

@vimtor vimtor self-assigned this Dec 13, 2025
@vimtor
Copy link
Collaborator

vimtor commented Dec 13, 2025

planning on testing this by next week!
stay tuned @MrHertal

Copy link
Collaborator

@vimtor vimtor left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for the contribution @MrHertal

the only issue i found is file uploads with "oac-with-edge-signing":

Image

this happens because the body bytes are being corrupted by converting to UTF-8 before hashing. which means that any request with non-UTF-8 bytes will fail, not just file uploads. i've suggested a fixed that seems to work

i've left other minor suggestions but everything else worked like a charm

runtime: "nodejs22.x",
handler: "index.handler",
role: edgeRole.arn,
code: new pulumi.asset.AssetArchive({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we move this function code to platform/functions instead of inline? just to keep it consistent with the rest of the codebase.

you'll find more examples in there

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, much better. I applied the change here: 61f63e2

Comment on lines +1332 to +1347
// Get the request body
let bodyString = '';

if (request.body && request.body.data) {
// Lambda@Edge provides body as base64-encoded string
if (request.body.encoding === 'base64') {
// Decode base64 to get the actual body content
bodyString = Buffer.from(request.body.data, 'base64').toString('utf8');
} else {
// If not base64 encoded, use as-is
bodyString = request.body.data;
}
}

// Compute SHA256 hash of the body
const hash = crypto.createHash('sha256').update(bodyString, 'utf8').digest('hex');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the below code fixed the file upload issue

still, lambda@edge has a 1mb payload cap, so big uploads return the failed to verify hash error

maybe we can return a different error or put a heads-up in the docs

Suggested change
// Get the request body
let bodyString = '';
if (request.body && request.body.data) {
// Lambda@Edge provides body as base64-encoded string
if (request.body.encoding === 'base64') {
// Decode base64 to get the actual body content
bodyString = Buffer.from(request.body.data, 'base64').toString('utf8');
} else {
// If not base64 encoded, use as-is
bodyString = request.body.data;
}
}
// Compute SHA256 hash of the body
const hash = crypto.createHash('sha256').update(bodyString, 'utf8').digest('hex');
// Get the request body as raw bytes (never convert to UTF-8 string)
const bodyBuffer = request.body?.data
? (request.body.encoding === 'base64'
? Buffer.from(request.body.data, 'base64')
: Buffer.from(request.body.data))
: Buffer.alloc(0);
// Compute SHA256 hash of the raw bytes
const hash = crypto.createHash('sha256').update(bodyBuffer).digest('hex');

Copy link
Contributor Author

@MrHertal MrHertal Dec 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch, I had not tested the file upload, shame on me.
I've added your suggested code, a note about the file size limit and also a better error type: 0791c64

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't worry. your pr is one of the few that worked first try haha

* }
* ```
*/
lambdaProtection?: Input<
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it’d be cool if we had this in the Router component too, so you’ve got protection when using it with an api for example.

not something to change for this pr, but maybe it makes sense to move this type to Router

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea! I'll keep this PR focused on SSR sites, but I can open a follow-up issue to add lambdaProtection support to Router. At that point we can refactor the type to a shared location.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good! let me know if you want me to create the issue

| "oac"
| "oac-with-edge-signing"
| {
mode: "oac-with-edge-signing";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when deleting a stage that uses "oac-with-edge-signing" it takes ~5-10 minutes for the replicated functions to be deleted, so the edge function deletion takes a while

maybe we can add a small ::note in the docs

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note added: e675073

@MrHertal
Copy link
Contributor Author

Thank you @vimtor for your relevant comments.
It's great to see how things are moving with you and the new support team.

Could you test my last changes? I could not find a way to properly build the lambda@edge.
Before I was copying components right in a project with SST to check if they worked.

Copy link
Collaborator

@vimtor vimtor left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i've tested the code and it works!

Image

just left a couple of suggestions before merging

* ```js
* // No protection (default)
* {
* lambdaProtection: "none"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i was thinking about how we'd name this attribute once we move it to the Router component

as far as i know you can protect lambdas and buckets for the most part. would we have a bucketProtection? not sure if that makes sense

this got me thinking maybe we should move this to the router and require people to pass the router instance to the SsrSite if they wanna use this feature

what do you think @MrHertal?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback @vimtor

Regarding buckets: Looking at the Router code, bucket routes already have OAC protection enabled by default (via originAccessControlId). The specific security violation I'm addressing is public Lambda Function URLs - tools like Prisma Cloud flag these as security risks, but S3 buckets behind CloudFront with OAC are fine.

Regarding Router support: Agreed this would be useful there too! The Router's url routes currently use customOriginConfig without OAC. In a future PR, we could add something like:

router.route("/api/*", api.url, {
  protection: "oac" // or "oac-with-edge-signing"
})

For this PR, I'd prefer to keep the scope limited to SSR sites since:

  1. SSR sites are the most common case for Lambda function URLs
  2. The protection is tightly coupled with how SSR sites create their Lambda functions (setting authorization: "iam" on the function URL)
  3. It works today without requiring users to change how they create their sites

Once this lands, we can discuss the best API for Router in a follow-up. The type definitions and Lambda@Edge function could be shared. What do you think?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MrHertal i love this api you suggested for the router. you're right, let's focus first on ssr sites and then we do the router.

the only thing missing then (i promise) is the lambdaProtection arg name

given that the router will have protection i wonder if it would be a better, more elegant name (in the sst philosophy style) for the arg. what do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, let me rename it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done! d11b8f2

Copy link
Collaborator

@vimtor vimtor left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

approved! great work @MrHertal

i will try to get this merged this week

i've tested the Next.js and Nuxt examples

protection: "none" | undefined

Image Image Image

protection: "oac"

Image Image Image

protection: "oac-with-edge-signing"

Image Image Image

@edbramwell
Copy link

@MrHertal great work here & @vimtor too - looking forward to testing this (and then removing my monkey-patch)

@masterFlippy
Copy link

Anyone else running in to issues with DELETE request? "message": "The request signature we calculated does not match the signature you provided. Check your AWS Secret Access Key and signing method. Consult the service documentation for details."

@vimtor
Copy link
Collaborator

vimtor commented Feb 16, 2026

i remember something about the DELETE requests when i was looking into this

@masterFlippy can you open an issue? ideally add a sample of your sst.config.ts

@MrHertal any ideas?

vimtor added a commit to mkilp/sst that referenced this pull request Feb 16, 2026
… access using OAC (anomalyco#5581)

* feat: add option to protect ssr server with origin access control

* feat: use metadata in key value store

* fix: parent self

* docs: add an example of usage with tanstack start

* fix: add a better example

* chore: remove outdated example

* feat: protect nextjs image optimiser

* feat: lambda at edge

* chore: trim comments

* chore(review): move function to right folder

* chore(review): handle file upload

* chore(review): add note about lambda@edge long deletion

* chore(review): update platform/functions/oac-edge-signer/index.ts

Co-authored-by: Victor Navarro <vn4varro@gmail.com>

* chore(review): prettier

* chore(review): rename arg

---------

Co-authored-by: Victor Navarro <vn4varro@gmail.com>
@masterFlippy
Copy link

masterFlippy commented Feb 19, 2026

i remember something about the DELETE requests when i was looking into this

@masterFlippy can you open an issue? ideally add a sample of your sst.config.ts

@MrHertal any ideas?

Yeah sure! I'll do it during the day!

#6427

vimtor added a commit to yoerayo/sst that referenced this pull request Mar 2, 2026
… access using OAC (anomalyco#5581)

* feat: add option to protect ssr server with origin access control

* feat: use metadata in key value store

* fix: parent self

* docs: add an example of usage with tanstack start

* fix: add a better example

* chore: remove outdated example

* feat: protect nextjs image optimiser

* feat: lambda at edge

* chore: trim comments

* chore(review): move function to right folder

* chore(review): handle file upload

* chore(review): add note about lambda@edge long deletion

* chore(review): update platform/functions/oac-edge-signer/index.ts

Co-authored-by: Victor Navarro <vn4varro@gmail.com>

* chore(review): prettier

* chore(review): rename arg

---------

Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.