<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Johannes Geiger on Medium]]></title>
        <description><![CDATA[Stories by Johannes Geiger on Medium]]></description>
        <link>https://medium.com/@johannesfloriangeiger?source=rss-70fbdd10c64f------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*CM1Eg20AkEcj9NRfVC8h_Q.jpeg</url>
            <title>Stories by Johannes Geiger on Medium</title>
            <link>https://medium.com/@johannesfloriangeiger?source=rss-70fbdd10c64f------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Wed, 04 Nov 2026 23:07:33 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@johannesfloriangeiger/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[DIY Build Server with AWS Lambda MicroVMs and S3 Files]]></title>
            <link>https://medium.com/@johannesfloriangeiger/diy-build-server-with-aws-lambda-microvms-and-s3-files-ee44a5226aa1?source=rss-70fbdd10c64f------2</link>
            <guid isPermaLink="false">https://medium.com/p/ee44a5226aa1</guid>
            <category><![CDATA[aws-lambda]]></category>
            <category><![CDATA[aws-lambda-microvm]]></category>
            <category><![CDATA[aws-s3]]></category>
            <category><![CDATA[golang]]></category>
            <category><![CDATA[java]]></category>
            <dc:creator><![CDATA[Johannes Geiger]]></dc:creator>
            <pubDate>Sat, 24 Oct 2026 11:19:08 GMT</pubDate>
            <atom:updated>2026-10-24T11:19:08.435Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/332/1*3XteANwnKTM4YRtU5jEsWg.png" /></figure><p>Ever since AWS announced <a href="https://aws.amazon.com/lambda/lambda-microvms/">AWS Lambda MicroVMs</a> I was thinking of use cases where Serverless stateful Docker based virtual machines can demonstrate a use case that goes past a very basic and artificial “Hello World” where we run an application that proves how subsequent invocations keep local state. Inspired by performance problems with build pipelines from current and previous real world projects, a recurring issue that requires resources on demand and benefits from keeping state in between invocations is a Build Server that both allows users to spin up instances on demand (and to scale those instances down when not needed, e.g. over night) and that the same instances will keep build artefacts such as dependencies between runs to optimise runtime whilst at the same time don’t require users to handle the case of a brand new instance spun up as the build tool will take care of transparently downloading dependencies if they aren’t available at the time of build.</p><p>The setup will be that we will be storing two “Hello World” applications (one written in Golang to test cross-compilation and one written in Java built with Maven to test the caching of dependencies on the MicroVM between runs) in S3, exposing it via S3 Files to a AWS Lambda MicroVM that on invoke will build either application on the MicroVM and writing the resulting artefacts back onto S3.</p><h3>Setup</h3><h4>Bucket and Filesystem</h4><p>For this I am reusing the Stack defined in my article on <a href="https://medium.com/@johannesfloriangeiger/mounting-s3-files-into-aws-lambda-3a100deecf2b">S3 Files and AWS Lambda</a>. So that afterwards we will have a new, empty Bucket with a Filesystem, Mount Targets and Security Group.</p><p>If we would now deploy an EC2 instance into one of the new Subnets we could mount the Filesystem and access it to prove the setup works so far. In case you do that, don’t forget to allow your EC2 instance’s Security Group access to the Mount Target’s Security Group.</p><h4>MicroVM Image</h4><p>I am generally using default values for the new MicroVM image, only to ensure the “Enable all operating system capabilities” flag is active. The ZIP archive we are using as the basis for the MicroVM image consists of five files: Dockerfile, three shell scripts (to mount S3 Files, build Go and build Java) and the application itself that will be running on the MicroVM. I’ve added all files in an appendix below, create a new archive containing those five files, upload it to an S3 Bucket and point the MicroVM’s “S3 destination” to it.</p><p>Create the new MicroVM image and check the logs for potential errors, the build might take several minutes. Afterwards, the state should be “Successful”.</p><h3>Demo</h3><p>Before we can run the MicroVM itself we both need a new IAM role and a File Connector allow the MicroVM to access S3 Files.</p><p>The IAM role needs to be manually created in the IAM section and have the relevant permissions to access S3 (I gave the role temporarily AdministratorAccess to avoid too much time spent on this) and to trust the Lambda service</p><pre>{<br>    &quot;Version&quot;: &quot;2012-10-17&quot;,<br>    &quot;Statement&quot;: [<br>        {<br>            &quot;Effect&quot;: &quot;Allow&quot;,<br>            &quot;Principal&quot;: {<br>                &quot;Service&quot;: &quot;lambda.amazonaws.com&quot;<br>            },<br>            &quot;Action&quot;: &quot;sts:AssumeRole&quot;<br>        }<br>    ]<br>}</pre><p>The File Connector can be created from the MicroVM creation screen, sitting in the same Subnets as we used for the File System above and its Security Group needs to be able to access the File System’s Security Group. The remaining settings can be left at defaults.</p><p>Now we can run the MicroVM with the image we built in the last step, using the role we just created and making sure the “Egress network interface” is set to the File Connector we just created. The MicroVM should come up almost immediately.</p><p>To demo, we need to copy the example projects into the Bucket, I chose to put both into separate ‘folders’, so that the Bucket has a ‘go’ and a ‘java’ root folder now, containing the respective example. I’ve added the example files I used in the appendix below again.</p><p>Finally, we can now invoke the MicroVM, in my case I did that manually from my local machine with JavaScript (replace the endpoint with the correct one and obtain a valid token via the AWS Console for the MicroVM, replace the File System ID with the correct one from your setup):</p><pre>const endpoint = &#39;&lt;ENDPOINT&gt;&#39;;<br>const token = &#39;&lt;TOKEN&gt;&#39;;<br>const type = &#39;java&#39;<br>const projectPath = &#39;./java&#39;;<br>const mountPath = &#39;/mnt/app&#39;;<br>const fileSystemId = &#39;&lt;FILE_SYSTEM_ID&gt;&#39;;<br>const response = await fetch(`https://${endpoint}`, {<br>    method: &#39;POST&#39;,<br>    headers: {<br>        &#39;X-aws-proxy-auth&#39;: token,<br>        &#39;Content-Type&#39;: &#39;application/json&#39;,<br>    },<br>    body: JSON.stringify({<br>        type,<br>        projectPath,<br>        mountPath,<br>        fileSystemId,<br>    })<br>});<br>console.log(await response.json());</pre><p>This builds the example Java application under the /java ‘folder’ and will place the resulting Application-1.0-SNAPSHOT.jar in the Bucket under the /target ‘folder’. Download it and run it with java -jar Application-1.0-SNAPSHOT.jar and you should see [main] INFO org.example.Main — Hello World! . Note that the response from the MicroVM contains a lot of ‘Maven dependency download’ noise that on subsequent runs is absent as the Maven repository on the MicroVM is kept between runs. The same build can be invoked for the Go application as well, replacing the type with go and specifying the right location in the Bucket.</p><h3>Summary</h3><p>Giving a MicroVM access to S3 works well but can be fiddly at times and more convenience from AWS side’ would be very helpful. The MicroVM performance is great and the ability to re-use MicroVM to optimise builds by re-using cached information I could imagine opens up a lot of possibilities for use cases where intermediate artefacts are heavily relied upon, such as building Java application with Maven where a lot of dependencies are needed.</p><h3>Appendix</h3><h4>MicroVM</h4><p><strong>Dockerfile</strong></p><pre>FROM public.ecr.aws/amazonlinux/amazonlinux:2023<br><br>RUN dnf -y install amazon-efs-utils nfs-utils nodejs golang java-21-amazon-corretto maven \<br>    &amp;&amp; dnf clean all \<br>    &amp;&amp; rm -rf /var/cache/dnf<br><br>RUN mkdir /mnt/app<br><br>WORKDIR /app<br>COPY --chmod=+x mount-s3.sh .<br>COPY --chmod=+x build-go.sh .<br>COPY --chmod=+x build-java.sh .<br>COPY app.js .<br><br>EXPOSE 8080<br><br>CMD [&quot;node&quot;, &quot;app.js&quot;]</pre><p><strong>mount-s3.sh</strong></p><pre>MOUNT_PATH=$1<br>FILE_SYSTEM_ID=$2<br>echo &quot;Mounting file system &#39;$FILE_SYSTEM_ID&#39; under &#39;$MOUNT_PATH&#39;...&quot;<br>mount -t s3files &quot;$FILE_SYSTEM_ID&quot; &quot;$MOUNT_PATH&quot;<br>echo &quot;File system mounted.&quot;</pre><p><strong>build-go.sh</strong></p><p>As I am writing this on a Mac but we’re building on Linux I have added the cross-compile flags GOOS=darwin GOARCH=amd64 so that I can execute the resulting application locally.</p><pre>MOUNT_PATH=$1<br>PROJECT_PATH=$2<br>echo &quot;Building application under mount path &#39;$MOUNT_PATH&#39; and project path &#39;$PROJECT_PATH&#39; with Go...&quot;<br>cd &quot;$MOUNT_PATH&quot; || exit<br>cd &quot;$PROJECT_PATH&quot; || exit<br>env GOOS=darwin GOARCH=amd64 go build<br>echo &quot;Application built.&quot;</pre><p><strong>build-java.sh</strong></p><pre>MOUNT_PATH=$1<br>PROJECT_PATH=$2<br>echo &quot;Building application under mount path &#39;$MOUNT_PATH&#39; and project path &#39;$PROJECT_PATH&#39; with Maven...&quot;<br>cd &quot;$MOUNT_PATH&quot; || exit<br>cd &quot;$PROJECT_PATH&quot; || exit<br>mvn clean install<br>echo &quot;Application built.&quot;</pre><p><strong>app.js</strong></p><pre>const http = require(&#39;http&#39;);<br>const {exec} = require(&#39;child_process&#39;);<br><br>const TYPE_GO = &#39;go&#39;;<br>const TYPE_JAVA = &#39;java&#39;;<br><br>const populateResponse = (response, status, content, stdout, stderr) =&gt; {<br>    response.writeHead(status, {<br>        &#39;Content-Type&#39;: &#39;application/json&#39;<br>    });<br><br>    response.end(JSON.stringify({<br>        content,<br>        stdout,<br>        stderr,<br>    }));<br>}<br><br>const server = http.createServer((req, res) =&gt; {<br>    console.log(&#39;Request received&#39;);<br><br>    let body = &#39;&#39;;<br>    req.on(&#39;data&#39;, (chunk) =&gt; {<br>        body += chunk;<br>    });<br>    req.on(&#39;end&#39;, () =&gt; {<br>        const content = JSON.parse(body);<br>        const {type, mountPath, fileSystemId, projectPath} = content;<br><br>        if (type === TYPE_GO) {<br>            exec(`./mount-s3.sh ${mountPath} ${fileSystemId} &amp;&amp; ./build-go.sh ${mountPath} ${projectPath}`, (err, stdout, stderr) =&gt; {<br>                if (err) {<br>                    populateResponse(res, 500, content, stdout, stderr);<br>                } else {<br>                    populateResponse(res, 200, content, stdout, stderr);<br>                }<br>            });<br>        } else if (type === TYPE_JAVA) {<br>            exec(`./mount-s3.sh ${mountPath} ${fileSystemId} &amp;&amp; ./build-java.sh ${mountPath} ${projectPath}`, (err, stdout, stderr) =&gt; {<br>                if (err) {<br>                    populateResponse(res, 500, content, stdout, stderr);<br>                } else {<br>                    populateResponse(res, 200, content, stdout, stderr);<br>                }<br>            });<br>        }<br>    });<br>});<br><br>server.listen(8080, () =&gt; {<br>    console.log(&#39;Listening on port 8080&#39;);<br>});</pre><h4>Example Project</h4><p><strong>main.go</strong></p><pre>package main<br><br>import &quot;fmt&quot;<br><br>func main() {<br>    fmt.Println(&quot;Hello&quot;, &quot;World!&quot;)<br>}</pre><p><strong>go.mod</strong></p><pre>module github.com/johannesfloriangeiger/go</pre><p><strong>pom.xml</strong></p><pre>&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;<br>&lt;project xmlns=&quot;http://maven.apache.org/POM/4.0.0&quot;<br>         xmlns:xsi=&quot;http://www.w3.org/2001/XMLSchema-instance&quot;<br>         xsi:schemaLocation=&quot;http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd&quot;&gt;<br>    &lt;modelVersion&gt;4.0.0&lt;/modelVersion&gt;<br><br>    &lt;groupId&gt;org.example&lt;/groupId&gt;<br>    &lt;artifactId&gt;Application&lt;/artifactId&gt;<br>    &lt;version&gt;1.0-SNAPSHOT&lt;/version&gt;<br><br>    &lt;properties&gt;<br>        &lt;maven.compiler.source&gt;17&lt;/maven.compiler.source&gt;<br>        &lt;maven.compiler.target&gt;17&lt;/maven.compiler.target&gt;<br>        &lt;project.build.sourceEncoding&gt;UTF-8&lt;/project.build.sourceEncoding&gt;<br>    &lt;/properties&gt;<br><br>    &lt;dependencies&gt;<br>        &lt;dependency&gt;<br>            &lt;groupId&gt;org.slf4j&lt;/groupId&gt;<br>            &lt;artifactId&gt;slf4j-api&lt;/artifactId&gt;<br>            &lt;version&gt;2.0.20&lt;/version&gt;<br>        &lt;/dependency&gt;<br>        &lt;dependency&gt;<br>            &lt;groupId&gt;org.slf4j&lt;/groupId&gt;<br>            &lt;artifactId&gt;slf4j-simple&lt;/artifactId&gt;<br>            &lt;version&gt;2.0.20&lt;/version&gt;<br>        &lt;/dependency&gt;<br>    &lt;/dependencies&gt;<br><br>    &lt;build&gt;<br>        &lt;plugins&gt;<br>            &lt;plugin&gt;<br>                &lt;groupId&gt;org.apache.maven.plugins&lt;/groupId&gt;<br>                &lt;artifactId&gt;maven-shade-plugin&lt;/artifactId&gt;<br>                &lt;version&gt;3.6.2&lt;/version&gt;<br>                &lt;executions&gt;<br>                    &lt;execution&gt;<br>                        &lt;phase&gt;package&lt;/phase&gt;<br>                        &lt;goals&gt;<br>                            &lt;goal&gt;shade&lt;/goal&gt;<br>                        &lt;/goals&gt;<br>                    &lt;/execution&gt;<br>                &lt;/executions&gt;<br>            &lt;/plugin&gt;<br>            &lt;plugin&gt;<br>                &lt;groupId&gt;org.apache.maven.plugins&lt;/groupId&gt;<br>                &lt;artifactId&gt;maven-jar-plugin&lt;/artifactId&gt;<br>                &lt;version&gt;3.5.1&lt;/version&gt;<br>                &lt;configuration&gt;<br>                    &lt;archive&gt;<br>                        &lt;manifest&gt;<br>                            &lt;addClasspath&gt;true&lt;/addClasspath&gt;<br>                            &lt;mainClass&gt;org.example.Main&lt;/mainClass&gt;<br>                        &lt;/manifest&gt;<br>                    &lt;/archive&gt;<br>                &lt;/configuration&gt;<br>            &lt;/plugin&gt;<br>        &lt;/plugins&gt;<br>    &lt;/build&gt;<br>&lt;/project&gt;</pre><p><strong>src/main/java/org/example/Main.java</strong></p><pre>package org.example;<br><br>import org.slf4j.LoggerFactory;<br><br>public class Main {<br><br>    public static void main(final String[] args) {<br>        LoggerFactory.getLogger(Main.class)<br>                .info(&quot;Hello World!&quot;);<br>    }<br>}</pre><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ee44a5226aa1" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Lambda Web Functions, CloudFront, and custom domain names]]></title>
            <link>https://medium.com/@johannesfloriangeiger/lambda-web-functions-cloudfront-and-custom-domain-names-ea67fcbc04c1?source=rss-70fbdd10c64f------2</link>
            <guid isPermaLink="false">https://medium.com/p/ea67fcbc04c1</guid>
            <category><![CDATA[lambda-web-function]]></category>
            <category><![CDATA[nodejs]]></category>
            <category><![CDATA[aws-lambda]]></category>
            <category><![CDATA[aws-route53]]></category>
            <category><![CDATA[aws-cloudfront]]></category>
            <dc:creator><![CDATA[Johannes Geiger]]></dc:creator>
            <pubDate>Fri, 16 Oct 2026 08:17:05 GMT</pubDate>
            <atom:updated>2026-10-16T08:17:05.407Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/400/1*3OsRQuCH9K2K_eDEUH16Ag.png" /></figure><p>In addition to ‘classic’ Lambdas which AWS have now rebranded Event Functions, AWS now also allows to host Web Functions which (for now) run Serverless Node.js web servers <a href="https://aws.amazon.com/about-aws/whats-new/2026/10/cloudfront-lambda-web-functions-origin/">that are now also supported as Origins of CloudFront</a> which makes it possible to run websites consisting of static HTML and dynamic API purely based on Web Functions, distributed by CloudFront and using a custom domain name, with a minimal infrastrucutre setup. In the following we will be looking at a step by step process to deploy an example application, using a custom domain name managed in Route53, with Web Functions and CloudFront only.</p><h3>Stage 1: Lambda</h3><p>The application supports two routes: A static HTML page that allows invoking an API, and said “dynamic” API that returns a text via GET (that otherwise would probably be read from a data source such as DynamoDB but to keep the example simple returns a static text for now). There are three files necessary for this:</p><p>First the package.json:</p><pre>{<br>  &quot;name&quot;: &quot;lambda-web-function&quot;,<br>  &quot;version&quot;: &quot;1.0.0&quot;,<br>  &quot;type&quot;: &quot;module&quot;,<br>  &quot;main&quot;: &quot;index.js&quot;,<br>  &quot;scripts&quot;: {<br>    &quot;dev&quot;: &quot;node index.js&quot;<br>  }<br>}</pre><p>then the index.html:</p><pre>&lt;!DOCTYPE html&gt;<br>&lt;html lang=&quot;en&quot;&gt;<br>&lt;head&gt;<br>    &lt;title&gt;Lambda Web Function&lt;/title&gt;<br>&lt;/head&gt;<br>&lt;body&gt;<br>&lt;div&gt;<br>    &lt;button id=&quot;greet&quot;<br>            onclick=&quot;fetch(&#39;/greet&#39;).then(response =&gt; response.text()).then(greeting =&gt; document.getElementById(&#39;greeting&#39;).innerText = greeting)&quot;&gt;<br>        Press to Greet<br>    &lt;/button&gt;<br>    &lt;p id=&quot;greeting&quot;&gt;&lt;/p&gt;<br>&lt;/div&gt;<br>&lt;/body&gt;<br>&lt;/html&gt;</pre><p>and finally the index.js:</p><pre>import {createServer} from &#39;http&#39;;<br>import {readFileSync} from &#39;fs&#39;;<br><br>const server = createServer((req, res) =&gt; {<br>    if (req.url === &#39;/greet&#39;) {<br>        res.writeHead(200, {&#39;Content-Type&#39;: &#39;text/plain&#39;});<br>        res.end(&#39;Hello Lambda Web Function!&#39;);<br>    } else {<br>        try {<br>            const html = readFileSync(&#39;index.html&#39;, &#39;utf8&#39;);<br>            res.writeHead(200, {&#39;Content-Type&#39;: &#39;text/html&#39;});<br>            res.end(html);<br>        } catch (error) {<br>            res.writeHead(404);<br>            res.end(&#39;Not Found&#39;);<br>        }<br>    }<br>});<br><br>const [host, port] = process.env.AWS_LAMBDA_HTTP_ENDPOINT.split(&#39;:&#39;);<br><br>server.listen(port, host, () =&gt; {<br>    console.log(`Server running on ${host}:${port}`);<br>});</pre><p>to test this locally, simply run AWS_LAMBDA_HTTP_ENDPOINT=localhost:3000 npm run dev, open <a href="http://localhost:3000/">http://localhost:3000</a> in a Browser and see that the page loads and the request to load the greeting works as well.</p><p>Creating a ZIP file containing those three files is then needed for the next step.</p><h3>Stage 2: The Infrastructure</h3><h4>The Lambda</h4><p>In the Lambda section of the AWS Console we create a new Web Function based off the previously created ZIP archive (I am doing that in eu-west-1 ), using the “Function name”: “LambdaWebFunction”, “Endpoint name”: “LambdaWebFunction” and the “Authentication type”: “IAM authentication”.</p><p>If we now try to invoke the Endpoint after creation we see the expected “403 Forbidden” as we have configured the Lambda to require IAM/SigV4 as part of a request.</p><h4>The CloudFront Distribution</h4><p>In the CloudFront section of the AWS Console we create a new CF Distribution, I was using “Pay as you go”, the “Distribution name”: “LambdaWebFunction” and the aforementioned custom domain. Select “Origin type”: “Lambda function” and select the previously created Lambda in the dialog which should populate CF with the Lambda Endpoint URL. Enable “Allow private Lambda function access to CloudFront” and leave the other options on their defaults. I skipped security for now, created a TLS certificate in ACM for the domain including the Route53 record needed to validate the certificate and after the CF distribution was created successfully also created the necessary Route53 records to route traffic to the domain to the CF Distribution.</p><p>After the CF Distribution was successfully deployed (which can take several minutes), invoking either the autogenerated CF domain or the aforementioned domain leads to an expected “403 Forbidden” again as the Lambda currently doesn’t allow CF to invoke itself.</p><h4>Access Control</h4><p>Finally back at the Lambda we create a Resource-based policy similar to the following</p><pre>{<br>    &quot;Version&quot;: &quot;2012-10-17&quot;,<br>    &quot;Statement&quot;: [<br>        {<br>            &quot;Sid&quot;: &quot;AllowCloudFrontOACAccess&quot;,<br>            &quot;Effect&quot;: &quot;Allow&quot;,<br>            &quot;Principal&quot;: {<br>                &quot;Service&quot;: &quot;cloudfront.amazonaws.com&quot;<br>            },<br>            &quot;Action&quot;: &quot;lambda:InvokeWebFunctionEndpoint&quot;,<br>            &quot;Resource&quot;: &quot;arn:aws:lambda:&lt;REGION&gt;:&lt;ACCOUNT_ID&gt;:web-function/LambdaWebFunction/*&quot;,<br>            &quot;Condition&quot;: {<br>                &quot;StringEquals&quot;: {<br>                    &quot;aws:SourceArn&quot;: &quot;arn:aws:cloudfront::&lt;ACCOUNT_ID&gt;:distribution/&lt;DISTRIBUTION_ID&gt;&quot;<br>                }<br>            }<br>        }<br>    ]<br>}</pre><p>that allows our CF Distribution (identified by Account ID and Distribution ID) to invoke all endpoints of our Lambda.</p><h3>Stage 3: Demo and Summary</h3><p>Invoking either the autogenerated CF domain or the aforementioned domain (which might require you to flush your DNS cache) now work and we can see both the static HTML page and also the request to the API working.</p><p>Setting up a Node.js based application serving both static content and also API worked relatively well, infrastructure support would obviously be nice and the ability to run the Web Function locally without significant overhead is a nice addition. Whether Web Functions have a large enough unique selling point over hosting static websites in S3 and API via ‘classic’ Lambdas, either behind an API Gateway or Application Load Balancer, remains to be seen.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ea67fcbc04c1" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Private Outbound Identity Federation within AWS VPC]]></title>
            <link>https://medium.com/@johannesfloriangeiger/private-outbound-identity-federation-within-aws-vpc-db70cd8ad0a5?source=rss-70fbdd10c64f------2</link>
            <guid isPermaLink="false">https://medium.com/p/db70cd8ad0a5</guid>
            <category><![CDATA[jwt]]></category>
            <category><![CDATA[aws-lambda]]></category>
            <category><![CDATA[rust]]></category>
            <category><![CDATA[aws-vpc-endpoints]]></category>
            <category><![CDATA[aws-vpc]]></category>
            <dc:creator><![CDATA[Johannes Geiger]]></dc:creator>
            <pubDate>Mon, 12 Oct 2026 19:43:23 GMT</pubDate>
            <atom:updated>2026-10-12T19:43:23.235Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/322/1*i8CUaDjKSK_-m8tdO3Eb2w.png" /></figure><p>Unfortunately not all applications running within AWS are Serverless native and use IAM for Authorisation and Authentication, so the ability of AWS STS to issue short lived JWT that can be verified against JWKS can be a solution. As of recently, the service to issue and verify tokens can be reached via VPC Endpoints, without the traffic leaving the AWS network.</p><p>To prove this out, the scenario will be a simple Lambda that first creates a short-lived JWT and subsequently validates it using the JWKS of the Issuer, all privately within a VPC. The infrastructure will be set up with CDK in TypeScript, the Lambda in Rust.</p><h3>The Foundation</h3><p>The Stack containing the data needed to set up the private Lambda in a VPC:</p><pre>const vpcId = &#39;...&#39;;<br>const vpcCidrBlock = &#39;...&#39;;<br><br>const availabilityZones = [&#39;...&#39;, &#39;...&#39;, &#39;...&#39;]<br><br>const firstSubnetId = &#39;...&#39;<br>const secondSubnetId = &#39;...&#39;<br>const thirdSubnetId = &#39;...&#39;<br><br>const vpc = Vpc.fromVpcAttributes(this, &#39;VPC&#39;, {<br>    vpcId,<br>    vpcCidrBlock,<br>    availabilityZones,<br>});<br><br>const firstSubnet = Subnet.fromSubnetId(this, &#39;FirstSubnet&#39;, firstSubnetId);<br>const secondSubnet = Subnet.fromSubnetId(this, &#39;SecondSubnet&#39;, secondSubnetId);<br>const thirdSubnet = Subnet.fromSubnetId(this, &#39;ThirdSubnet&#39;, thirdSubnetId);<br>const subnets = [firstSubnet, secondSubnet, thirdSubnet];<br>const vpcSubnets = {<br>    availabilityZones,<br>    subnets,<br>} as SubnetSelection;<br><br>const lambda = new RustFunction(this, &#39;Lambda&#39;, {<br>    entry: &#39;lambda&#39;,<br>    functionName: &#39;Lambda&#39;,<br>    memorySize: 1024,<br>    vpc,<br>    vpcSubnets,<br>    logGroup: new LogGroup(this, &#39;LambdaLogGroup&#39;, {<br>        logGroupName: &#39;/aws/lambda/Lambda&#39;,<br>        removalPolicy: RemovalPolicy.DESTROY,<br>    }),<br>});<br>lambda.role?.attachInlinePolicy(new Policy(this, &#39;Policy&#39;, {<br>    statements: [<br>        new PolicyStatement({<br>            actions: [&#39;sts:GetWebIdentityToken&#39;],<br>            resources: [`arn:aws:sts::${this.account}:self`],<br>        })<br>    ]<br>}))</pre><p>and the Lambda:</p><pre>struct Handler&lt;&#39;a&gt; {<br>    client: &amp;&#39;a Client,<br>}<br><br>const AUDIENCE: &amp;&#39;static str = &quot;MyExternalApplication&quot;;<br><br>const SIGNING_ALGORITHM: &amp;&#39;static str = &quot;RS256&quot;;<br><br>#[derive(Serialize)]<br>struct Response {<br>    token: String,<br>}<br><br>impl&lt;&#39;a&gt; Handler&lt;&#39;a&gt; {<br>    async fn handle(&amp;self, _: LambdaEvent&lt;Value&gt;) -&gt; Result&lt;Response, Error&gt; {<br>        let token = self<br>            .client<br>            .get_web_identity_token()<br>            .audience(AUDIENCE)<br>            .signing_algorithm(SIGNING_ALGORITHM)<br>            .send()<br>            .await?<br>            .web_identity_token<br>            .unwrap();<br><br>        Ok(Response { token })<br>    }<br>}<br><br>#[tokio::main]<br>async fn main() -&gt; Result&lt;(), Error&gt; {<br>    let sdk_config = aws_config::load_defaults(BehaviorVersion::latest()).await;<br>    let client = Client::new(&amp;sdk_config);<br>    let handler = Handler {<br>        client: &amp;client,<br>    };<br>    run(service_fn(|lambda_event: LambdaEvent&lt;Value&gt;| {<br>        handler.handle(lambda_event)<br>    }))<br>    .await<br>}</pre><p>are both pretty straightforward. The Lambda resides in private Subnets in a VPC, requires IAM permissions to create the WebIdentityToken and uses the STS Client to create said token on invoke, using a fixed audience and algorithm. Invoking the Lambda leads to a timeout though: The STS client tries to reach the respective service via its public endpoint which isn’t reachable from the private Subnets that contain no route to the internet.</p><h3>Private Networking: Issuing the Token</h3><p>The solution to this is to use a VPC Endpoint, so expanding the Stack with the right VPC Endpoint that will allow the Lambda to reach STS:</p><pre>new InterfaceVpcEndpoint(this, &#39;VPCEndpointSTS&#39;, {<br>    vpc,<br>    service: InterfaceVpcEndpointAwsService.STS,<br>    subnets: vpcSubnets<br>});</pre><p>And on deployment and invoking the Lambda the token is now returned. Decoding the token leads to the confirmation that aud is being set to MyExternalApplication , sub is the role of our Lambda and iss is the Token Issuer URL that can be found in the Outbound Identity Federation section of the IAM Account Settings.</p><h3>Verifying the Token</h3><p>Now for the second step, expand the setup to verify the token that’s just been created.</p><p>The Lambda is slightly extended:</p><pre>struct Handler&lt;&#39;a&gt; {<br>    client: &amp;&#39;a Client,<br>    issuer: String,<br>}<br><br>const AUDIENCE: &amp;&#39;static str = &quot;MyExternalApplication&quot;;<br><br>const SIGNING_ALGORITHM: &amp;&#39;static str = &quot;RS256&quot;;<br><br>#[derive(Deserialize)]<br>struct Claims {<br>    pub sub: String,<br>}<br><br>#[derive(Serialize)]<br>struct Response {<br>    token: String,<br>    subject: String,<br>}<br><br>impl&lt;&#39;a&gt; Handler&lt;&#39;a&gt; {<br>    async fn handle(&amp;self, _: LambdaEvent&lt;Value&gt;) -&gt; Result&lt;Response, Error&gt; {<br>        let token = self<br>            .client<br>            .get_web_identity_token()<br>            .audience(AUDIENCE)<br>            .signing_algorithm(SIGNING_ALGORITHM)<br>            .send()<br>            .await?<br>            .web_identity_token<br>            .unwrap();<br><br>        let header = decode_header(&amp;token)?;<br><br>        let jwks = Jwks::from_oidc_url(format!(&quot;{}/.well-known/openid-configuration&quot;, self.issuer))<br>            .await?;<br>        let kid = header.kid.unwrap();<br>        let jwk = jwks.keys.get(&amp;kid).unwrap();<br><br>        let mut validation = Validation::new(header.alg);<br>        validation.validate_exp = true;<br>        validation.validate_nbf = true;<br>        validation.set_issuer(&amp;[self.issuer.clone()]);<br>        validation.set_audience(&amp;[AUDIENCE]);<br><br>        let token_data = decode::&lt;Claims&gt;(&amp;token, &amp;jwk.decoding_key, &amp;validation)?;<br>        let subject = token_data.claims.sub;<br><br>        Ok(Response { token, subject })<br>    }<br>}<br><br>#[tokio::main]<br>async fn main() -&gt; Result&lt;(), Error&gt; {<br>    let sdk_config = aws_config::load_defaults(BehaviorVersion::latest()).await;<br>    let client = Client::new(&amp;sdk_config);<br>    let issuer = env::var(&quot;ISSUER&quot;)?;<br>    let handler = Handler {<br>        client: &amp;client,<br>        issuer,<br>    };<br>    run(service_fn(|lambda_event: LambdaEvent&lt;Value&gt;| {<br>        handler.handle(lambda_event)<br>    }))<br>    .await<br>}</pre><p>now reading the Issuer from an environment variable, using it to obtain JWKS and verifying the token whilst the infrastructure also gets slightly extended:</p><pre>const uuid = &#39;...&#39;;<br>const zoneName = &#39;tokens.sts.global.api.aws&#39;;<br><br>const lambda = new RustFunction(this, &#39;Lambda&#39;, {<br>    entry: &#39;lambda&#39;,<br>    environment: {<br>        ISSUER: `https://${uuid}.${zoneName}`,<br>    },<br>    functionName: &#39;Lambda&#39;,<br>    memorySize: 1024,<br>    vpc,<br>    vpcSubnets,<br>    logGroup: new LogGroup(this, &#39;LambdaLogGroup&#39;, {<br>        logGroupName: &#39;/aws/lambda/Lambda&#39;,<br>        removalPolicy: RemovalPolicy.DESTROY,<br>    }),<br>});</pre><p>by passing the Issuer to the Lambda.</p><p>Invoking the Lambda now leads to a timeout again as the Lambda tries to read the JWKS from the OpenID-configuration via the Issuer which again is not a routable URL for a Lambda in private Subnets.</p><h3>Private Networking: Verifying the Token</h3><p>So, lastly the solution is similar to above: A VPC Endpoint, just this time for STS-OIDC and not STS. And because the Issuer URL (see above) does not use the com.amazonaws.&lt;REGION&gt;.sts-oidc URL and instead the &lt;UUID&gt;.tokens.sts.global.api.aws URL we also need to create a Private Hosted Zone and a Route53 Alias that ensures that the Lambda when using the Issuer will instead resolve the VPC Endpoint:</p><pre>const privateHostedZone = new PrivateHostedZone(this, &#39;PrivateHostedZone&#39;, {<br>    vpc,<br>    zoneName,<br>});<br>const interfaceVpcEndpoint = new InterfaceVpcEndpoint(this, &#39;VPCEndpointSTSOIDC&#39;, {<br>    vpc,<br>    service: new InterfaceVpcEndpointAwsService(&#39;sts-oidc&#39;),<br>    subnets: vpcSubnets<br>});<br>const interfaceVpcEndpointTarget = new InterfaceVpcEndpointTarget(interfaceVpcEndpoint);<br>new ARecord(this, &#39;ARecord&#39;, {<br>    zone: privateHostedZone,<br>    recordName: uuid,<br>    target: RecordTarget.fromAlias(interfaceVpcEndpointTarget),<br>})</pre><p>and thus, on deploying the last change, the Lambda can now also verify the JWT and extracting the subject from it successfully.</p><h3>Summary</h3><p>Whilst I still only see limited use of Outbound Identity Federation the possibility for non-Serverless-native applications to trust JWT including a completely VPC-private verification is a nice addition to the existing feature set.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=db70cd8ad0a5" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AWS API Gateway Integration with mutual TLS]]></title>
            <link>https://medium.com/@johannesfloriangeiger/aws-api-gateway-integration-with-mutual-tls-933d57f60a68?source=rss-70fbdd10c64f------2</link>
            <guid isPermaLink="false">https://medium.com/p/933d57f60a68</guid>
            <category><![CDATA[aws-cdk]]></category>
            <category><![CDATA[typescript]]></category>
            <category><![CDATA[mutual-tls]]></category>
            <category><![CDATA[aws-api-gateway]]></category>
            <category><![CDATA[application-load-balancer]]></category>
            <dc:creator><![CDATA[Johannes Geiger]]></dc:creator>
            <pubDate>Mon, 14 Sep 2026 19:40:58 GMT</pubDate>
            <atom:updated>2026-09-14T19:43:06.248Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/440/1*5xNmlcvQfuu4DEJxOnrcOw.png" /></figure><p>In the past AWS API Gateways were only able to integrate with plain HTTP or HTTPS URL, <a href="https://aws.amazon.com/about-aws/whats-new/2026/09/amazon-api-gateway-mutual-tls-backend/">now they also support integrating with backends that require mutual TLS</a>. I found the SAM example a bit overly complex so I reproduced the same: Feature an Application Load Balancer (ALB), in CDK with TypeScript, almost completely self contained.</p><h3>The Setup</h3><h4>Preparation</h4><p>In preparation for this proof of concept we assume to manage a Route53 domain in AWS already. We also have an S3 Bucket at hand and a couple of public Subnets. Finally, we have created a Certificate for our domain as well.</p><p>The first part of our Stack will be the imports of resources that already exist:</p><pre>const vpcId = &#39;&lt;VPC-ID&gt;&#39;;<br>const availabilityZones = [&#39;eu-central-1&#39;, &#39;eu-central-2&#39;];<br>const vpc = Vpc.fromVpcAttributes(this, &#39;VPC&#39;, {<br>    vpcId,<br>    availabilityZones,<br>});<br><br>const bucketArn = &#39;&lt;BUCKET-ARN&gt;&#39;;<br>const bucket = Bucket.fromBucketArn(this, &#39;Bucket&#39;, bucketArn);<br><br>const firstSubnetId = &#39;&lt;FIRST-SUBNET-ID&gt;&#39;;<br>const firstSubnet = Subnet.fromSubnetId(this, &#39;FirstSubnet&#39;, firstSubnetId);<br>const secondSubnetId = &#39;&lt;SECOND-SUBNET-ID&gt;&#39;;<br>const secondSubnet = Subnet.fromSubnetId(this, &#39;SecondSubnet&#39;, secondSubnetId);<br>const subnets = [firstSubnet, secondSubnet];<br>const vpcSubnets = {<br>    subnets,<br>} as SubnetSelection;<br><br>const certificateArn = &#39;&lt;CERTIFICATE-ARN&gt;&#39;;<br>const certificate = Certificate.fromCertificateArn(this, &#39;Certificate&#39;, certificateArn);<br>const listenerCertificate = ListenerCertificate.fromCertificateManager(certificate);<br>const certificates = [listenerCertificate];<br><br>const hostedZoneId = &#39;&lt;HOSTED-ZONE-ID&gt;&#39;;<br>const zoneName = &#39;&lt;ZONE-NAME&gt;&#39;;<br>const zone = HostedZone.fromHostedZoneAttributes(this, &#39;HostedZone&#39;, {<br>    hostedZoneId,<br>    zoneName,<br>});</pre><p>We are importing a VPC, a Bucket (that will hold the TrustStore later), the Subnets our ALB will be deployed into, the ALB’s public certificate that ensures the communication works with TLS in the first place and finally the Hosted Zone that holds the DNS information for our domain.</p><h4>The Application Load Balancer</h4><p>Next up, we set up the ALB with CDK:</p><pre>const securityGroup = new SecurityGroup(this, &#39;SecurityGroup&#39;, {<br>    vpc,<br>})<br>securityGroup.addIngressRule(Peer.anyIpv4(), Port.HTTPS)<br><br>const applicationLoadBalancer = new ApplicationLoadBalancer(this, &#39;ALB&#39;, {<br>    vpc,<br>    internetFacing: true,<br>    vpcSubnets,<br>    securityGroup,<br>});<br><br>const trustStore = new TrustStore(this, &#39;TrustStore&#39;, {<br>    bucket,<br>    key: &#39;truststore.pem&#39;,<br>});<br><br>applicationLoadBalancer.addListener(&#39;Listener&#39;, {<br>    protocol: ApplicationProtocol.HTTPS,<br>    certificates,<br>    defaultAction: ListenerAction.fixedResponse(200, {<br>        contentType: &#39;text/plain&#39;,<br>        messageBody: &#39;Hello, mutual TLS authenticated World!&#39;<br>    }),<br>    mutualAuthentication: {<br>        mutualAuthenticationMode: MutualAuthenticationMode.VERIFY,<br>        trustStore,<br>    }<br>})<br><br>const loadBalancerTarget = new LoadBalancerTarget(applicationLoadBalancer);<br>const target = RecordTarget.fromAlias(loadBalancerTarget);<br>new ARecord(this, &#39;ARecord&#39;, {<br>    target,<br>    zone,<br>})</pre><p>For this, we create a Security Group (SG) that allows Ingress for any IPv4 address and the internet-facing ALB in the VPC/Subnets with said SG. We then define a TrustStore based off the Bucket we imported and add an HTTPS listener to the ALB that runs with the imported certificates and returns a fixed plain text message plus requires a valid client certificate that will be verified against the TrustStore. We finally create an A record in the Hosted Zone to allow clients to resolve the domain against our ALB’s DNS name. Before we continue we need to have created a Private Certificate Authority, stored its certificate in the Bucket and Key mentioned in the TrustStore above and have created a client certificate for that Private CA as well. Please note that a Private CA is a quite expensive resource and shouldn’t be kept longer than absolutely needed.</p><h4>The API Gateway</h4><p>Finally we can now create the API Gateway:</p><pre>const clientCertificateId = &#39;&lt;CLIENT-CERTIFICATE-ARN&gt;&#39;;<br><br>const api = new RestApi(this, &#39;API&#39;, {<br>    deploy: false,<br>});<br>api.root.addMethod(&#39;GET&#39;, new Integration({<br>    type: IntegrationType.HTTP_PROXY,<br>    uri: `https://${zoneName}`,<br>    integrationHttpMethod: &#39;GET&#39;,<br>}))<br>const deployment = new Deployment(this, &#39;Deployment&#39;, {<br>    api,<br>});<br>const plainStage = new Stage(this, &#39;PlainStage&#39;, {<br>    deployment: deployment,<br>    stageName: &#39;plain&#39;<br>});<br>const mtlsStage = new Stage(this, &#39;MtlsStage&#39;, {<br>    deployment: deployment,<br>    stageName: &#39;mtls&#39;,<br>    clientCertificateId,<br>});<br><br>new CfnOutput(this, &#39;PlainURL&#39;, {<br>    key: &#39;PlainURL&#39;,<br>    value: plainStage.urlForPath()<br>})<br>new CfnOutput(this, &#39;MTLSURL&#39;, {<br>    key: &#39;MTLSURL&#39;,<br>    value: mtlsStage.urlForPath()<br>})</pre><p>Here we define our API with a single GET method that integrates with our ALB (via the domain name) and create 2 stages: One “plain” stage without a client certificate and one “mtls” stage with a client certificate (the only difference is specifying the clientCertificateId field). Note that the client certificate is not set on the resource but on the stage which would e.g. allow us to have a backend without mTLS for development purposes and the backend with mTLS in production.</p><h4>Demo</h4><p>To demo the setup, simply invoke either URL: The PlainURL request fails with a connection error (because the API GW tries to proxy to a backend that expects a client certificate without providing a client certificate) whilst the MTLSURL works as expected.</p><h3>Summary</h3><p>The setup is straightforward and the integration is seamless, the fact that ALB’s require the mTLS CA to be uploaded to an S3 Bucket is a bit clunky though.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=933d57f60a68" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AWS Lambdas with Java, revisited]]></title>
            <link>https://medium.com/@johannesfloriangeiger/aws-lambdas-with-java-revisited-f74e7ed214d5?source=rss-70fbdd10c64f------2</link>
            <guid isPermaLink="false">https://medium.com/p/f74e7ed214d5</guid>
            <category><![CDATA[quarkus]]></category>
            <category><![CDATA[java]]></category>
            <category><![CDATA[aws-lambda]]></category>
            <category><![CDATA[snapstart]]></category>
            <category><![CDATA[docker]]></category>
            <dc:creator><![CDATA[Johannes Geiger]]></dc:creator>
            <pubDate>Sat, 05 Sep 2026 14:08:24 GMT</pubDate>
            <atom:updated>2026-09-05T14:11:28.739Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/350/1*VP7kEBJPUx6MitiiErsfGQ.png" /></figure><p>Ever since the first time I have researched best practices, performance tweaks and tooling for the use of synchronous Java Lambdas in my day job I have kept an eye on new developments that might make developers life easier and improve the experience of using Java in Lambda context. AWS recently published that <a href="https://aws.amazon.com/about-aws/whats-new/2026/07/aws-lambda-snapstart-container/">AWS Lambda now supports SnapStart for container image functions</a> so I wanted to take a look how containerized Java Lambdas with SnapStart perform against Plain Java Lambdas and Quarkus and how the developer experience is looking like.</p><h3>The Setup</h3><p>To be able to make informed decisions we first need a way to reproduce comparable runtime behaviour. The following setup doesn&#39;t claim to be scientific but it tries to mimic a common use case for Lambdas, reading a small file from an S3 Bucket. The scenarios are going to be:</p><ol><li>Plain Java Lambda</li><li>Plain Java Lambda with SnapStart enabled</li><li>Containerized Java Lambda</li><li>Containerized Java Lambda with SnapStart enabled</li><li>Native Quarkus Lambda</li></ol><p>and to provision the infrastructure and to stay on topic we are going to use CDK with Java.</p><h4>The Lambdas</h4><p>First, the Plain Java Lambda that is going to be used for the first four scenarios:</p><pre>package com.example.myapp;<br><br>import com.amazonaws.services.lambda.runtime.Context;<br>import com.amazonaws.services.lambda.runtime.RequestHandler;<br>import software.amazon.awssdk.services.s3.S3Client;<br>import software.amazon.awssdk.services.s3.model.GetObjectRequest;<br><br>import java.io.IOException;<br><br>public class App implements RequestHandler&lt;App.Request, App.Response&gt; {<br><br>    public record Request(String bucket, String key) {<br>    }<br><br>    public record Response(String content) {<br>    }<br><br>    private final S3Client s3Client;<br><br>    public App() {<br>        this.s3Client = S3Client.builder()<br>                .build();<br>    }<br><br>    @Override<br>    public Response handleRequest(final Request request, final Context context) {<br>        final var getObjectRequest = GetObjectRequest.builder()<br>                .bucket(request.bucket)<br>                .key(request.key)<br>                .build();<br><br>        try (final var responseInputStream = this.s3Client.getObject(getObjectRequest)) {<br>            final var bytes = responseInputStream.readAllBytes();<br>            final var content = new String(bytes);<br><br>            return new Response(content);<br>        } catch (IOException e) {<br>            throw new RuntimeException(e);<br>        }<br>    }<br>}</pre><p>and the Dockerfile used to create the image for scenarios 3 and 4:</p><pre>FROM public.ecr.aws/lambda/java:21<br><br>COPY target/classes ${LAMBDA_TASK_ROOT}<br>COPY target/dependency/* ${LAMBDA_TASK_ROOT}/lib/<br><br>CMD [ &quot;com.example.myapp.App::handleRequest&quot; ]</pre><p>Secondly, the Quarkus Java Lambda used in the fifth scenario:</p><pre>package com.example.myapp;<br><br>import com.amazonaws.services.lambda.runtime.Context;<br>import com.amazonaws.services.lambda.runtime.RequestHandler;<br>import software.amazon.awssdk.services.s3.S3Client;<br>import software.amazon.awssdk.services.s3.model.GetObjectRequest;<br><br>import java.io.IOException;<br><br>public class App implements RequestHandler&lt;App.Request, App.Response&gt; {<br><br>    public record Request(String bucket, String key) {<br>    }<br><br>    public record Response(String content) {<br>    }<br><br>    private final S3Client client;<br><br>    public App() {<br>        this.client = S3Client.builder().build();<br>    }<br><br>    @Override<br>    public Response handleRequest(final Request request, final Context context) {<br>        final var getObjectRequest = GetObjectRequest.builder()<br>                .bucket(request.bucket)<br>                .key(request.key)<br>                .build();<br><br>        try (final var responseInputStream = this.client.getObject(getObjectRequest)) {<br>            final var bytes = responseInputStream.readAllBytes();<br>            final var content = new String(bytes);<br><br>            return new Response(content);<br>        } catch (final IOException e) {<br>            throw new RuntimeException(e);<br>        }<br>    }<br>}</pre><p>When building on a Mac (like I did), when creating Docker images or native executables as usual cross-compile for Linux:</p><pre>docker buildx build --platform linux/amd64 --provenance=false -t myapp:latest .</pre><p>explicitly stating the platform as linux/amd64, respectively</p><pre>quarkus build --native --no-tests -Dquarkus.native.container-build=true -Dquarkus.native.builder-image=quay.io/quarkus/ubi9-quarkus-mandrel-builder-image:25.0.4.1-Final-java25-amd64</pre><p>explicitly stating the Quarkus native builder image as amd64.</p><h4>The Stack</h4><p>Now, having built the JAR, the Docker image and the Quarkus native executable, we can create the infrastructure:</p><pre>final var bucket = Bucket.fromBucketName(this, &quot;Bucket&quot;, &quot;&lt;BUCKET-NAME&gt;&quot;);<br><br>final var lambda = Function.Builder.create(this, &quot;MyAppLambda&quot;)<br>        .functionName(&quot;MyApp&quot;)<br>        .logGroup(LogGroup.Builder.create(this, &quot;MyAppLogGroup&quot;)<br>                .removalPolicy(RemovalPolicy.DESTROY)<br>                .build())<br>        .memorySize(1024)<br>        .timeout(Duration.seconds(30))<br>        .code(Code.fromAsset(&quot;../myapp/target/myapp.jar&quot;))<br>        .handler(&quot;com.example.myapp.App::handleRequest&quot;)<br>        .runtime(Runtime.JAVA_21)<br>        .build();<br>bucket.grantRead(lambda);<br><br>final var snapStartLambda = Function.Builder.create(this, &quot;MyAppSnapStartLambda&quot;)<br>        .functionName(&quot;MyAppSnapStart&quot;)<br>        .logGroup(LogGroup.Builder.create(this, &quot;MyAppSnapStartLogGroup&quot;)<br>                .removalPolicy(RemovalPolicy.DESTROY)<br>                .build())<br>        .memorySize(1024)<br>        .timeout(Duration.seconds(30))<br>        .snapStart(SnapStartConf.ON_PUBLISHED_VERSIONS)<br>        .code(Code.fromAsset(&quot;../myapp/target/myapp.jar&quot;))<br>        .handler(&quot;com.example.myapp.App::handleRequest&quot;)<br>        .runtime(Runtime.JAVA_21)<br>        .build();<br>bucket.grantRead(snapStartLambda);<br>Version.Builder.create(this, &quot;MyAppSnapStartLambdaVersion&quot;)<br>        .lambda(snapStartLambda)<br>        .build();<br><br>final var repository = Repository.fromRepositoryArn(this, &quot;MyAppRepository&quot;, &quot;arn:aws:ecr:&lt;REGION&gt;:&lt;ACCOUNT&gt;:repository/myapp&quot;);<br><br>final var containerizedLambda = Function.Builder.create(this, &quot;MyContainerizedAppLambda&quot;)<br>        .functionName(&quot;MyContainerizedApp&quot;)<br>        .logGroup(LogGroup.Builder.create(this, &quot;MyContainerizedAppLogGroup&quot;)<br>                .removalPolicy(RemovalPolicy.DESTROY)<br>                .build())<br>        .memorySize(1024)<br>        .timeout(Duration.seconds(30))<br>        .code(EcrImageCode.fromEcrImage(repository))<br>        .handler(Handler.FROM_IMAGE)<br>        .runtime(Runtime.FROM_IMAGE)<br>        .build();<br>bucket.grantRead(containerizedLambda);<br><br>final var containerizedSnapStartLambda = Function.Builder.create(this, &quot;MyContainerizedAppSnapStartLambda&quot;)<br>        .functionName(&quot;MyContainerizedAppSnapStart&quot;)<br>        .logGroup(LogGroup.Builder.create(this, &quot;MyContainerizedAppSnapStartLogGroup&quot;)<br>                .removalPolicy(RemovalPolicy.DESTROY)<br>                .build())<br>        .memorySize(1024)<br>        .timeout(Duration.seconds(30))<br>        .snapStart(SnapStartConf.ON_PUBLISHED_VERSIONS)<br>        .code(EcrImageCode.fromEcrImage(repository))<br>        .handler(Handler.FROM_IMAGE)<br>        .runtime(Runtime.FROM_IMAGE)<br>        .build();<br>bucket.grantRead(containerizedSnapStartLambda);<br>Version.Builder.create(this, &quot;MyContainerizedAppSnapStartLambdaVersion&quot;)<br>        .lambda(containerizedSnapStartLambda)<br>        .build();<br><br>final var quarkusLambda = Function.Builder.create(this, &quot;MyAppQuarkusLambda&quot;)<br>        .functionName(&quot;MyAppQuarkus&quot;)<br>        .logGroup(LogGroup.Builder.create(this, &quot;MyAppQuarkusLogGroup&quot;)<br>                .removalPolicy(RemovalPolicy.DESTROY)<br>                .build())<br>        .memorySize(1024)<br>        .timeout(Duration.seconds(30))<br>        .code(Code.fromAsset(&quot;../myapp-quarkus/target/function.zip&quot;))<br>        .handler(&quot;not.used.in.provided.runtime.&quot;)<br>        .runtime(Runtime.PROVIDED_AL2023)<br>        .build();<br>bucket.grantRead(quarkusLambda);</pre><p>Note that all Lambdas run with the same memory size.</p><h3>The Statistics</h3><p>I invoked all five Lambdas repeatedly over the course of a day, gathering a series of data points that varied in detail but usually ended up in a similar region per type of Lambda. One detail I learnt about later is that the AWS SDK often requires to be “warmed up” manually as it’s lazily loading resources but as this “workaround” hasn’t been used anywhere I would assume it would only affect the absolute number and not the relative size of the cold start invocation compared between the different types of scenarios. The runtime for warm starts doesn&#39;t differ significantly between the Lambdas so the statistics below concentrates on cold starts.</p><h4>Memory</h4><p>Memory usage across the first four scenarios averaged at around <em>160 MB</em>, Quarkus only used <em>70 MB</em> of memory. As comparison, the same functionality written in Rust uses around <em>35 MB</em> of memory.</p><h4>Runtime</h4><p>Runtime behaviour between the five scenarios is roughly that the Plain Java Lambda (<em>~2.9</em>s) runs twice as long as the Plain Java Lambda with SnapStart enabled (<em>~1.5s</em>), the Containerized Lambda (<em>~2.3s</em>) is around 25% faster than the Plain Java Lambda but significantly slower than the Plain Java Lambda with SnapStart enabled and the Containerized Java Lambda with SnapStart enabled (<em>~1.1s</em>) is roughly 2.5 times as fast as the Plain Java Lambda. The Quarkus Lambda finally returns in <em>0.5s</em> and is thus again the fastest Lambda version between the five scenarios. Again as comparison, the same functionality written in Rust takes around <em>300ms</em>.</p><p>In addition to that, for the SnapStart enabled scenarios the Lambdas need to publish a version which adds minor complexity to the infrastructure which might affect the decision to use SnapStart or to simply switch to Quarkus instead.</p><h3>Summary</h3><p>The differences between the scenarios are significant, for synchronous Lambdas I don’t see any reason not to use Quarkus if using the Java ecosystem is a mandatory criterium or to move to a compiled language altogether. For asynchronous invocations where cold starts are of less relevance staying with Plain Java Lambdas is the least amount of effort although I personally really like the Quarkus developer experience and tooling so at least personally I would most likely even use Quarkus for asynchronous Lambdas.</p><p>Apart from that, if for governance or other reasons the use of containerized Java Lambdas has to be considered, SnapStart looks to be a very promising addition here, although I would like the AWS SDK to support that directly via a dedicated warming up mechanism without the need for users to perform dummy API requests during initialisation as that’s quite clunky and not very transparent at the moment.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=f74e7ed214d5" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Headless Machine-to-Machine Authorization with AWS Cognito]]></title>
            <link>https://medium.com/@johannesfloriangeiger/headless-machine-to-machine-authorization-with-aws-cognito-515008355b30?source=rss-70fbdd10c64f------2</link>
            <guid isPermaLink="false">https://medium.com/p/515008355b30</guid>
            <category><![CDATA[aws-api-gateway]]></category>
            <category><![CDATA[aws-cognito]]></category>
            <category><![CDATA[typescript]]></category>
            <category><![CDATA[aws-lambda]]></category>
            <category><![CDATA[oauth]]></category>
            <dc:creator><![CDATA[Johannes Geiger]]></dc:creator>
            <pubDate>Wed, 02 Sep 2026 06:43:31 GMT</pubDate>
            <atom:updated>2026-09-02T06:43:31.946Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/360/1*llIwU6e3PYu7_nl9a5bHaA.png" /></figure><p>AWS Cognito has been supporting machine-to-machine communication before, for users with a Client ID and Secret, requiring users to get tokens via OAuth which meant performing an HTTP request to the Cognito User Pool Domain. <a href="https://aws.amazon.com/about-aws/whats-new/2026/08/amazon-cognito-get-client-token/">Now with AWS Cognito supporting machine-to-machine authorization without a user pool domain</a> it is possible to get a token with Client ID and Secret via AWS SDK and CLI alone so the following describes the minimal setup to obtain and use the token as part of a simple REST API.</p><h3>The Setup</h3><p>Using CDK with TypeScript, setting up a Lambda behind an API Gateway that will use the AWS provided Cognito User Pools Authorizer to minimize the amount of custom code needed. The Lambda will extract the sub (i.e. in this case the Client ID) and respond with it. As CDK currently doesn’t support the machine-to-machine authorization without a user pool domain with their Cognito constructs a few tweaks are needed and as the GetClientToken API results in an Access Token which by default isn’t trusted by the Cognito User Pools Authorizer on its own, a OAuth scope also needs to be defined on the client and the API to enable using Access Tokens to successfully communicate with the API.</p><h4>The Lambda</h4><p>The Lambda is a pretty straightforward TypeScript-Lambda that extracts the sub and returns it as part of a message:</p><pre>exports.handler = async (event: any) =&gt; {<br>    return {<br>        isBase64Encoded: false,<br>        statusCode: 200,<br>        headers: {},<br>        body: `Hello ${event.requestContext.authorizer.claims.sub}!`,<br>    }<br>}</pre><h4>The Stack</h4><p>The Stack defines a Lambda, a Cognito User Pool with a Client that allows machine-to-machine using a scope and a REST API that makes use of the pool in its Authorizer:</p><pre>const lambda = new NodejsFunction(this, &#39;Lambda&#39;, {<br>    functionName: &#39;Lambda&#39;,<br>    logGroup: new LogGroup(this, &#39;LambdaLogGroup&#39;, {<br>        logGroupName: &#39;LambdaLogGroup&#39;,<br>        removalPolicy: RemovalPolicy.DESTROY,<br>    }),<br>    entry: &#39;./lambda/index.ts&#39;,<br>    handler: &#39;handler&#39;<br>});<br><br>const userPool = new UserPool(this, &#39;UserPool&#39;, {<br>    userPoolName: &#39;users&#39;,<br>    removalPolicy: RemovalPolicy.DESTROY,<br>});<br><br>const resourceServerScope = new ResourceServerScope({<br>    scopeName: &#39;read&#39;,<br>    scopeDescription: &#39;API Read Access&#39;,<br>});<br>const userPoolResourceServer = userPool.addResourceServer(&#39;ResourceServer&#39;, {<br>    identifier: &#39;resource-server&#39;,<br>    scopes: [<br>        resourceServerScope,<br>    ],<br>});<br>const authorizationScope = `${userPoolResourceServer.userPoolResourceServerId}/${resourceServerScope.scopeName}`;<br><br>const userPoolClient = userPool.addClient(&#39;Client&#39;, {<br>    userPoolClientName: &#39;headless-client&#39;,<br>    generateSecret: true,<br>    disableOAuth: true,<br>});<br>const cfnUserPoolClient = userPoolClient.node.defaultChild as CfnUserPoolClient;<br>cfnUserPoolClient.addPropertyOverride(&#39;ExplicitAuthFlows&#39;, [&#39;ALLOW_CLIENT_TOKEN_AUTH&#39;]);<br>cfnUserPoolClient.addPropertyOverride(&#39;AllowedOAuthScopes&#39;, [authorizationScope]);<br><br>const cognitoUserPoolsAuthorizer = new CognitoUserPoolsAuthorizer(this, &#39;CognitoUserPoolsAuthorizer&#39;, {<br>    authorizerName: &#39;CognitoUserPoolsAuthorizer&#39;,<br>    cognitoUserPools: [userPool]<br>});<br><br>const restApi = new RestApi(this, &#39;APIGateway&#39;);<br>restApi.root.addMethod(&#39;GET&#39;, new LambdaIntegration(lambda), {<br>    authorizationType: AuthorizationType.COGNITO,<br>    authorizer: cognitoUserPoolsAuthorizer,<br>    authorizationScopes: [authorizationScope]<br>})<br><br>new Deployment(this, &#39;Deployment&#39;, {<br>    api: restApi,<br>})<br><br>new CfnOutput(this, &#39;ClientId&#39;, {<br>    key: &#39;ClientId&#39;,<br>    value: userPoolClient.userPoolClientId<br>});<br><br>new CfnOutput(this, &#39;ClientSecret&#39;, {<br>    key: &#39;ClientSecret&#39;,<br>    value: userPoolClient.userPoolClientSecret.unsafeUnwrap()<br>})</pre><p>As mentioned before as CDK currently doesn’t support setting up the client directly the approach is to define a client that generates a secret but explicitly disables OAuth and to override the properties using the L1 construct afterwards:</p><pre>const userPoolClient = userPool.addClient(&#39;Client&#39;, {<br>    userPoolClientName: &#39;headless-client&#39;,<br>    generateSecret: true,<br>    disableOAuth: true,<br>});<br>const cfnUserPoolClient = userPoolClient.node.defaultChild as CfnUserPoolClient;<br>cfnUserPoolClient.addPropertyOverride(&#39;ExplicitAuthFlows&#39;, [&#39;ALLOW_CLIENT_TOKEN_AUTH&#39;]);<br>cfnUserPoolClient.addPropertyOverride(&#39;AllowedOAuthScopes&#39;, [authorizationScope]);</pre><h3>The Demo</h3><p>After deploying the Stack and extracting the URL, the Client ID and the Client Secret, first an Access Token is requested using ID, Secret and the Scope defined above:</p><pre>TOKEN=$(aws cognito-idp get-client-token \<br>    --client-id $CLIENT_ID \<br>    --secret $CLIENT_SECRET \<br>    --scopes resource-server/read \<br>    | jq -r &#39;.ClientAuthenticationResult.AccessToken&#39;)</pre><p>Now just for the sake of completeness and to demo that the API is set up right, an unauthenticated request expecting an HTTP 401 is performed:</p><pre>curl -v $URL</pre><p>And finally using the Token that’s just been generated, an authenticated request expecting an HTTP 200 is performed:</p><pre>curl -v -H &quot;Authorization: Bearer $TOKEN&quot; $URL</pre><p>Which greets us with the Client ID that has been used to create the Access Token.</p><h3>Summary</h3><p>Whilst the setup in CDK is a bit clunky due to the missing L2 construct support the functionality works straightforward and removing the need to get a token indirect via the OAuth URL reduces effort and streamlines machine-to-machine communication.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=515008355b30" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AWS Lambdas, Secrets and Asynchronous Updates]]></title>
            <link>https://medium.com/@johannesfloriangeiger/aws-lambdas-secrets-and-asynchronous-updates-59ba2d46ba3a?source=rss-70fbdd10c64f------2</link>
            <guid isPermaLink="false">https://medium.com/p/59ba2d46ba3a</guid>
            <category><![CDATA[aws-lambda]]></category>
            <category><![CDATA[rust]]></category>
            <category><![CDATA[aws-eventbridge]]></category>
            <category><![CDATA[aws-cdk]]></category>
            <category><![CDATA[aws-secrets-manager]]></category>
            <dc:creator><![CDATA[Johannes Geiger]]></dc:creator>
            <pubDate>Sat, 25 Jul 2026 19:09:01 GMT</pubDate>
            <atom:updated>2026-07-25T19:09:01.562Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/306/1*PlW0phaWUV53qBprCWWXFA.png" /></figure><p>Dealing with confidential data in Lambdas is a common theme. Instead of hard-coding data in code, or passing it in as environment variables (that might be visible in the AWS console), confidential data can be stored in AWS SecretsManager and Lambdas can load secrets at runtime, which then leads to the next question: What if the confidential data gets updated? Common approaches are to use explicit time to live, or to handle failures when said confidential data is used (e.g. when it represents credentials that are being used to authenticate at a service and when a failing authentication is then used as a signal to refresh the credentials once. But what if the credentials are genuinely wrong and not just outdated, how to distinguish those use cases then?).</p><p>In the following a series of scenarios where Lambdas deal with secrets are presented that culminate in an approach that makes use of a new AWS feature to reload secrets when being notified.</p><h3>The Setup</h3><p>All following examples will be using the same basic setup: A Secret that contains a “Prefix” text that will be used to greet the user who invokes the Lambdas. To set up the infrastructure CDK with TypeScript will be used and the Lambdas are written in Rust.</p><pre>const secret = new Secret(this, &#39;Secret&#39;, {<br>    secretName: &#39;Prefix&#39;,<br>    secretStringValue: SecretValue.unsafePlainText(&#39;Hello, &#39;)<br>});</pre><h4>Read</h4><p>The simplest and naive way to read secrets is once per invocation:</p><p>First the Lambda is created</p><pre>use aws_config::BehaviorVersion;<br>use aws_sdk_secretsmanager::Client;<br>use lambda_runtime::{run, service_fn, Error, LambdaEvent};<br>use serde::Deserialize;<br>use std::env;<br><br>struct Handler&lt;&#39;a&gt; {<br>    secret_id: String,<br>    client: &amp;&#39;a Client,<br>}<br><br>#[derive(Deserialize, Debug)]<br>struct ReadRequest {<br>    name: String,<br>}<br><br>impl&lt;&#39;a&gt; Handler&lt;&#39;a&gt; {<br>    async fn handle(&amp;self, lambda_event: LambdaEvent&lt;ReadRequest&gt;) -&gt; Result&lt;String, Error&gt; {<br>        let pattern = self<br>            .client<br>            .get_secret_value()<br>            .secret_id(self.secret_id.as_str())<br>            .send()<br>            .await?<br>            .secret_string<br>            .unwrap();<br>        Ok(format!(&quot;{}{}&quot;, pattern, lambda_event.payload.name))<br>    }<br>}<br><br>#[tokio::main]<br>async fn main() -&gt; Result&lt;(), Error&gt; {<br>    let secret_id = env::var(&quot;SECRET_ID&quot;)?;<br><br>    let sdk_config = aws_config::load_defaults(BehaviorVersion::latest()).await;<br>    let client = Client::new(&amp;sdk_config);<br><br>    let handler = Handler {<br>        secret_id,<br>        client: &amp;client,<br>    };<br><br>    run(service_fn(|lambda_event: LambdaEvent&lt;ReadRequest&gt;| {<br>        handler.handle(lambda_event)<br>    }))<br>    .await<br>}</pre><p>And then the infrastructure is set up</p><pre>const readSecretLambda = new RustFunction(this, &#39;ReadSecret&#39;, {<br>    entry: &#39;read_secret&#39;,<br>    environment: {<br>        &#39;SECRET_ID&#39;: secret.secretArn,<br>    },<br>    functionName: &#39;ReadSecret&#39;,<br>    memorySize: 1024,<br>    logGroup: new LogGroup(this, &#39;ReadSecretLogGroup&#39;, {<br>        logGroupName: &#39;/aws/lambda/ReadSecret&#39;,<br>        removalPolicy: RemovalPolicy.DESTROY,<br>    }),<br>});<br>secret.grantRead(readSecretLambda);</pre><p>Performance wise this approach isn’t ideal: ~350ms for cold starts and ~30ms for warm starts. Can’t the secret be stored in between invocations?</p><h4>Lazy Read</h4><p>Following the idea of the previous step the secret is lazy loaded and stored in between invocations.</p><p>First the Lambda is created</p><pre>use aws_config::BehaviorVersion;<br>use aws_sdk_secretsmanager::Client;<br>use lambda_runtime::{Error, LambdaEvent, run, service_fn};<br>use serde::Deserialize;<br>use std::{env, sync::Mutex};<br><br>struct Handler&lt;&#39;a&gt; {<br>    secret_id: String,<br>    client: &amp;&#39;a Client,<br>    secret_value: Mutex&lt;Option&lt;String&gt;&gt;,<br>}<br><br>#[derive(Deserialize, Debug)]<br>struct ReadRequest {<br>    name: String,<br>}<br><br>impl&lt;&#39;a&gt; Handler&lt;&#39;a&gt; {<br>    async fn handle(&amp;self, lambda_event: LambdaEvent&lt;ReadRequest&gt;) -&gt; Result&lt;String, Error&gt; {<br>        match self.secret_value.lock() {<br>            Ok(mut guard) =&gt; {<br>                let pattern = match guard.as_ref() {<br>                    None =&gt; {<br>                        let pattern = self<br>                            .client<br>                            .get_secret_value()<br>                            .secret_id(self.secret_id.as_str())<br>                            .send()<br>                            .await?<br>                            .secret_string<br>                            .unwrap();<br>                        guard.replace(pattern.clone());<br>                        println!(&quot;Lazy-Loaded the Secret {}&quot;, self.secret_id);<br><br>                        pattern<br>                    }<br>                    Some(pattern) =&gt; {<br>                        println!(&quot;Secret {} already loaded&quot;, self.secret_id);<br><br>                        pattern.to_string()<br>                    }<br>                };<br><br>                Ok(format!(&quot;{}{}&quot;, pattern, lambda_event.payload.name))<br>            }<br>            Err(err) =&gt; Err(Error::from(err.to_string())),<br>        }<br>    }<br>}<br><br>#[tokio::main]<br>async fn main() -&gt; Result&lt;(), Error&gt; {<br>    let secret_id = env::var(&quot;SECRET_ID&quot;)?;<br><br>    let sdk_config = aws_config::load_defaults(BehaviorVersion::latest()).await;<br>    let client = Client::new(&amp;sdk_config);<br><br>    let secret_value = Mutex::new(None);<br><br>    let handler = Handler {<br>        secret_id,<br>        client: &amp;client,<br>        secret_value,<br>    };<br><br>    run(service_fn(|lambda_event: LambdaEvent&lt;ReadRequest&gt;| {<br>        handler.handle(lambda_event)<br>    }))<br>    .await<br>}</pre><p>And then the infrastructure is set up</p><pre>const readSecretLambdaLazy = new RustFunction(this, &#39;ReadSecretLazy&#39;, {<br>    entry: &#39;read_secret_lazy&#39;,<br>    environment: {<br>        &#39;SECRET_ID&#39;: secret.secretArn,<br>    },<br>    functionName: &#39;ReadSecretLazy&#39;,<br>    memorySize: 1024,<br>    logGroup: new LogGroup(this, &#39;ReadSecretLazyLogGroup&#39;, {<br>        logGroupName: &#39;/aws/lambda/ReadSecretLazy&#39;,<br>        removalPolicy: RemovalPolicy.DESTROY,<br>    }),<br>});<br>secret.grantRead(readSecretLambdaLazy);</pre><p>Performance wise this approach is similar to the previous one for cold starts but manages to achieve 1ms for warm starts.</p><h4>Preload</h4><p>Another idea is to load the secret on startup, considering it shouldn’t matter if the secret is lazy loaded or preloaded.</p><p>First the Lambda is created</p><pre>use aws_config::BehaviorVersion;<br>use aws_sdk_secretsmanager::Client;<br>use lambda_runtime::{run, service_fn, Error, LambdaEvent};<br>use serde::Deserialize;<br>use std::env;<br><br>struct Handler {<br>    secret_value: String,<br>}<br><br>#[derive(Deserialize, Debug)]<br>struct ReadRequest {<br>    name: String,<br>}<br><br>impl Handler {<br>    async fn handle(&amp;self, lambda_event: LambdaEvent&lt;ReadRequest&gt;) -&gt; Result&lt;String, Error&gt; {<br>        Ok(format!(<br>            &quot;{}{}&quot;,<br>            self.secret_value, lambda_event.payload.name<br>        ))<br>    }<br>}<br><br>#[tokio::main]<br>async fn main() -&gt; Result&lt;(), Error&gt; {<br>    let sdk_config = aws_config::load_defaults(BehaviorVersion::latest()).await;<br>    let client = Client::new(&amp;sdk_config);<br>    let secret_id = env::var(&quot;SECRET_ID&quot;)?;<br>    let secret_value = client<br>        .get_secret_value()<br>        .secret_id(secret_id)<br>        .send()<br>        .await?<br>        .secret_string<br>        .unwrap();<br>    let handler = Handler { secret_value };<br>    run(service_fn(|lambda_event: LambdaEvent&lt;ReadRequest&gt;| {<br>        handler.handle(lambda_event)<br>    }))<br>    .await<br>}</pre><p>And then the infrastructure is set up</p><pre>const preloadSecretLambda = new RustFunction(this, &#39;PreloadSecret&#39;, {<br>    entry: &#39;preload_secret&#39;,<br>    environment: {<br>        &#39;SECRET_ID&#39;: secret.secretArn,<br>    },<br>    functionName: &#39;PreloadSecret&#39;,<br>    memorySize: 1024,<br>    logGroup: new LogGroup(this, &#39;PreloadSecretLogGroup&#39;, {<br>        logGroupName: &#39;/aws/lambda/PreloadSecret&#39;,<br>        removalPolicy: RemovalPolicy.DESTROY,<br>    }),<br>});<br>secret.grantRead(preloadSecretLambda);</pre><p>Interestingly the cold starts get reduced to ~300ms but the warm starts are similarly performant as the last solutions.</p><h4>Preload with Updates</h4><p>Unfortunately all three previous approaches assume the secret will either never change or that if it changes in between invocations that this coincides with the Lambda environment being shutdown so that the next cold start will pick up the updated secret. For an approach that makes use of the AWS Workload Credentials Provider see <a href="https://medium.com/@johannesfloriangeiger/aws-workload-credentials-provider-for-aws-lambdas-hands-on-ae436604b573">https://medium.com/@johannesfloriangeiger/aws-workload-credentials-provider-for-aws-lambdas-hands-on-ae436604b573</a> for example.</p><p>But what if a Lambda could react to a changing secret to reload it independently? Recently AWS introduced a mechanism that so that <a href="https://aws.amazon.com/about-aws/whats-new/2026/07/secrets-manager-update-notifications/">AWS Secrets Manager now publishes secret update notifications to Amazon EventBridge</a> and so the idea of the last scenario is to use those events to invoke a Lambda, triggering a secret reload.</p><p>First the Lambda is created</p><pre>use aws_config::BehaviorVersion;<br>use aws_sdk_secretsmanager::Client;<br>use lambda_runtime::{Error, LambdaEvent, run, service_fn};<br>use serde::Deserialize;<br>use serde_json::Value;<br>use std::{env, sync::Mutex};<br><br>struct Handler&lt;&#39;a&gt; {<br>    secret_id: String,<br>    client: &amp;&#39;a Client,<br>    secret_value: Mutex&lt;Option&lt;String&gt;&gt;,<br>}<br><br>#[derive(Deserialize, Debug)]<br>struct ReadRequest {<br>    name: String,<br>}<br><br>impl&lt;&#39;a&gt; Handler&lt;&#39;a&gt; {<br>    async fn handle(&amp;self, lambda_event: LambdaEvent&lt;Value&gt;) -&gt; Result&lt;String, Error&gt; {<br>        match lambda_event.payload.get(&quot;name&quot;) {<br>            None =&gt; match lambda_event.payload.get(&quot;id&quot;) {<br>                None =&gt; {<br>                    println!(&quot;Unknown event&quot;);<br><br>                    Ok(&quot;&quot;.into())<br>                }<br>                Some(_) =&gt; match self.secret_value.lock() {<br>                    Ok(mut guard) =&gt; {<br>                        let pattern = self<br>                            .client<br>                            .get_secret_value()<br>                            .secret_id(self.secret_id.as_str())<br>                            .send()<br>                            .await?<br>                            .secret_string<br>                            .unwrap();<br>                        guard.replace(pattern.clone());<br>                        println!(&quot;Reloaded the Secret: {}&quot;, pattern);<br><br>                        Ok(&quot;&quot;.into())<br>                    }<br>                    Err(err) =&gt; Err(Error::from(err.to_string())),<br>                },<br>            },<br>            Some(_) =&gt; match self.secret_value.lock() {<br>                Ok(mut guard) =&gt; {<br>                    let pattern = guard.as_mut().unwrap();<br>                    let read_request: ReadRequest =<br>                        serde_json::from_value(lambda_event.payload.clone())?;<br><br>                    Ok(format!(&quot;{}{}&quot;, pattern, read_request.name))<br>                }<br>                Err(err) =&gt; Err(Error::from(err.to_string())),<br>            },<br>        }<br>    }<br>}<br><br>#[tokio::main]<br>async fn main() -&gt; Result&lt;(), Error&gt; {<br>    let sdk_config = aws_config::load_defaults(BehaviorVersion::latest()).await;<br>    let client = Client::new(&amp;sdk_config);<br>    let secret_id = env::var(&quot;SECRET_ID&quot;)?;<br>    let secret_value = client<br>        .get_secret_value()<br>        .secret_id(secret_id.clone())<br>        .send()<br>        .await?<br>        .secret_string<br>        .unwrap();<br>    let handler = Handler {<br>        secret_id,<br>        client: &amp;client,<br>        secret_value: Mutex::new(Some(secret_value)),<br>    };<br>    run(service_fn(|lambda_event: LambdaEvent&lt;Value&gt;| {<br>        handler.handle(lambda_event)<br>    }))<br>    .await<br>}</pre><p>And then the infrastructure is set up</p><pre>const secretEventBus = new EventBus(this, &#39;SecretEventBus&#39;, {<br>    eventBusName: &#39;SecretEventBus&#39;<br>});<br>const eventBusTarget = new EventBusTarget(secretEventBus);<br>const defaultEventBus = EventBus.fromEventBusName(this, &#39;DefaultEventBus&#39;, &#39;default&#39;);<br>new Rule(this, &#39;DefaultEventBusSecretRule&#39;, {<br>    ruleName: &#39;DefaultEventBusSecretRule&#39;,<br>    eventPattern: {<br>        detailType: [&#39;Secret Label Updated&#39;],<br>        source: [&#39;aws.secretsmanager&#39;],<br>        account: [this.account],<br>        detail: {<br>            name: [secret.secretName],<br>        }<br>    },<br>    targets: [eventBusTarget],<br>    eventBus: defaultEventBus,<br>});<br><br>const preloadSecretUpdatesLambda = new RustFunction(this, &#39;PreloadSecretUpdatesLambda&#39;, {<br>    entry: &#39;preload_secret_updates&#39;,<br>    environment: {<br>        &#39;SECRET_ID&#39;: secret.secretArn,<br>    },<br>    functionName: &#39;PreloadSecretUpdatesLambda&#39;,<br>    memorySize: 1024,<br>    logGroup: new LogGroup(this, &#39;PreloadSecretUpdatesLambdaLogGroup&#39;, {<br>        logGroupName: &#39;/aws/lambda/PreloadSecretUpdatesLambda&#39;,<br>        removalPolicy: RemovalPolicy.DESTROY,<br>    }),<br>});<br>secret.grantRead(preloadSecretUpdatesLambda);<br>const preloadSecretUpdatesLambdaTarget = new LambdaTarget(preloadSecretUpdatesLambda);<br>new Rule(this, &#39;PreloadSecretUpdatesLambdaRule&#39;, {<br>    ruleName: &#39;PreloadSecretUpdatesLambdaRule&#39;,<br>    eventPattern: {<br>        account: [this.account],<br>    },<br>    targets: [preloadSecretUpdatesLambdaTarget],<br>    eventBus: secretEventBus,<br>});</pre><p>The setup is a bit more complex than the last one because the Lambda now needs to be able to handle both EventBridge events and manual invocations (for this the Lambda checks if the event contains a <em>name</em> field for a manual invocation or an <em>id</em> field for an EventBridge invocation) and when receiving the EventBridge event it reloads the secret. Infrastructure wise the secret update events are being routed to a new “secret” event bus via an EventBridge rule from the default event bus (for clean separation) and a second EventBridge rule then invokes the Lambda for all events appearing in that “secret” event bus.</p><p>The performance is similar to the last scenario, the only difference is that changing the secret value will lead to an invocation that’s slightly higher (~50ms) but results in subsequent invocations that make use of the new secret value and thus avoid stale information.</p><h3>Summary</h3><p>Whilst the setup works relatively well and is relatively performant, dealing with multiple types of events for the Lambda in the last case is not very elegant and mixes different concerns in the same code. Ideally Lambdas would have a separate channel for notifications (perhaps also a potential improvement for the AWS Workload Credentials Provider?) that would allow for the secrets to be reloaded in the background.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=59ba2d46ba3a" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Event-Driven Architecture with Aurora DSQL Change Data Capture]]></title>
            <link>https://medium.com/@johannesfloriangeiger/event-driven-architecture-with-aurora-dsql-change-data-capture-39c35d7407e5?source=rss-70fbdd10c64f------2</link>
            <guid isPermaLink="false">https://medium.com/p/39c35d7407e5</guid>
            <category><![CDATA[rust]]></category>
            <category><![CDATA[aurora-dsql]]></category>
            <category><![CDATA[aurora]]></category>
            <category><![CDATA[aws-cdk]]></category>
            <category><![CDATA[aws-lambda]]></category>
            <dc:creator><![CDATA[Johannes Geiger]]></dc:creator>
            <pubDate>Tue, 14 Jul 2026 20:11:35 GMT</pubDate>
            <atom:updated>2026-07-14T20:11:35.620Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/679/1*fj5ePOyzee3HX5fhn2J2vQ.png" /></figure><p>Inspired by the <a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.html">DynamoDB feature of streaming change events to consumers via DynamoDB Streams</a> the <a href="https://aws.amazon.com/about-aws/whats-new/2026/07/amazon-aurora-dsql-cdc-ga/">news of Aurora DSQL now generally supporting Change Data Capture (CDC)</a> via Kinesis is an exciting one as implementing Event-Driven Architectures using a native feature of an SQL database reduces complexity on the application side and streamlines consumers by adopting a standardised pattern, in this case a Kinesis Data Stream.</p><h3>The Scenario</h3><p>As a proof of concept we are going to look at a relatively simple scenario of a Request Processor that accepts messages containing a URL and stores the status code of the HTTP response when calling that URL in a table. The process is initiated by a Create Requests Lambda that writes “Request Created” events into a database table, then picked up by the Dispatcher Lambda via the CDC Kinesis Stream, dispatched to a SQS Queue and finally picked up by the Process Requests Lambda that processes the requests (by calling the URL) and stores the status code as a “Request Processed” event in the table again. As the structure of the table is relatively simple we are going to use a Single Table design instead of one table per event type which would require some JOIN’s for reading the actual status of an event.</p><h3>The Infrastructure</h3><p>Now we need to create some infrastructure for the above, starting with the Aurora DSQL cluster and the Kinesis Data Stream that will be used for CDC (all using TypeScript CDK in the following):</p><pre>const cluster = new Cluster(this, &#39;Cluster&#39;, {<br>    removalPolicy: RemovalPolicy.DESTROY,<br>});<br><br>const stream = new Stream(this, &#39;Stream&#39;, {<br>    streamName: &#39;Stream&#39;,<br>    removalPolicy: RemovalPolicy.DESTROY,<br>});</pre><p>next we need an IAM role that Aurora DSQL is going to use to write to Kinesis:</p><pre>const cdcRole = new Role(this, &#39;CDCRole&#39;, {<br>    assumedBy: new ServicePrincipal(&#39;dsql&#39;),<br>    inlinePolicies: {<br>        Default: new PolicyDocument({<br>            statements: [<br>                new PolicyStatement({<br>                    actions: [<br>                        &#39;kinesis:DescribeStreamSummary&#39;,<br>                        &#39;kinesis:ListShards&#39;,<br>                        &#39;kinesis:PutRecord&#39;,<br>                        &#39;kinesis:PutRecords&#39;,<br>                    ],<br>                    resources: [<br>                        stream.streamArn<br>                    ]<br>                })<br>            ]<br>        })<br>    },<br>    roleName: &#39;CDCRole&#39;<br>});</pre><p>and to create the CDC Stream we currently need to use an AWS Custom Resource as CloudFormation is currently not supporting it, and for this we are referencing the cluster, the stream and the role created above:</p><pre>new AwsCustomResource(this, &#39;CreateCDCStream&#39;, {<br>    onCreate: {<br>        service: &#39;dsql&#39;,<br>        action: &#39;createStream&#39;,<br>        parameters: {<br>            clusterIdentifier: cluster.clusterId,<br>            targetDefinition: {<br>                kinesis: {<br>                    streamArn: stream.streamArn,<br>                    roleArn: cdcRole.roleArn,<br>                }<br>            },<br>            ordering: &#39;UNORDERED&#39;,<br>            format: &#39;JSON&#39;,<br>        },<br>        physicalResourceId: PhysicalResourceId.of(&#39;create-cdc-stream&#39;)<br>    },<br>    policy: AwsCustomResourcePolicy.fromStatements([<br>        new PolicyStatement({<br>            actions: [&#39;dsql:CreateStream&#39;],<br>            resources: [cluster.clusterArn]<br>        }),<br>        new PolicyStatement({<br>            actions: [&#39;iam:PassRole&#39;],<br>            resources: [cdcRole.roleArn]<br>        })<br>    ])<br>});</pre><p>Finally we need to create the three Lambdas, together with Kinesis and SQS Event Sources to invoke the Lambdas:</p><pre>const queue = new Queue(this, &#39;RequestQueue&#39;, {<br>    queueName: &#39;RequestQueue&#39;,<br>});<br><br>const createRequestLambda = new RustFunction(this, &#39;CreateRequest&#39;, {<br>    entry: &#39;create-request&#39;,<br>    environment: {<br>        &#39;CLUSTER_ENDPOINT&#39;: `${cluster.clusterId}.dsql.${this.region}.on.aws`,<br>    },<br>    functionName: &#39;CreateRequest&#39;,<br>    logGroup: new LogGroup(this, &#39;CreateRequestLogGroup&#39;, {<br>        logGroupName: &#39;/aws/lambda/CreateRequest&#39;,<br>        removalPolicy: RemovalPolicy.DESTROY,<br>    }),<br>});<br>cluster.grantConnectAdmin(createRequestLambda);<br><br>const processRequestLambda = new RustFunction(this, &#39;ProcessRequest&#39;, {<br>    entry: &#39;process-request&#39;,<br>    environment: {<br>        &#39;CLUSTER_ENDPOINT&#39;: `${cluster.clusterId}.dsql.${this.region}.on.aws`,<br>    },<br>    functionName: &#39;ProcessRequest&#39;,<br>    logGroup: new LogGroup(this, &#39;ProcessRequestLogGroup&#39;, {<br>        logGroupName: &#39;/aws/lambda/ProcessRequest&#39;,<br>        removalPolicy: RemovalPolicy.DESTROY,<br>    }),<br>});<br>cluster.grantConnectAdmin(processRequestLambda);<br>const sqsEventSource = new SqsEventSource(queue, {<br>    batchSize: 1,<br>});<br>processRequestLambda.addEventSource(sqsEventSource)<br><br>const dispatcherLambda = new RustFunction(this, &#39;Dispatcher&#39;, {<br>    entry: &#39;dispatcher&#39;,<br>    environment: {<br>        &#39;QUEUE_URL&#39;: queue.queueUrl,<br>    },<br>    functionName: &#39;Dispatcher&#39;,<br>    logGroup: new LogGroup(this, &#39;DispatcherLogGroup&#39;, {<br>        logGroupName: &#39;/aws/lambda/Dispatcher&#39;,<br>        removalPolicy: RemovalPolicy.DESTROY,<br>    }),<br>});<br>queue.grantSendMessages(dispatcherLambda);<br>const kinesisEventSource = new KinesisEventSource(stream, {<br>    startingPosition: StartingPosition.LATEST,<br>});<br>dispatcherLambda.addEventSource(kinesisEventSource);</pre><p>We are using the Aurora DSQL Admin user for this for simplicities sake, for a more fine grained setup of Aurora DSQL see <a href="https://medium.com/@johannesfloriangeiger/amazon-aurora-dsql-with-lambdas-revisited-and-this-time-with-rust-ece95306b902">https://medium.com/@johannesfloriangeiger/amazon-aurora-dsql-with-lambdas-revisited-and-this-time-with-rust-ece95306b902</a>.</p><p>After having deployed everything we are logging into Aurora DSQL via the AWS Console and create the table structure:</p><pre>CREATE TABLE public.requests (id UUID, request_type VARCHAR(255), url VARCHAR(255), status_code VARCHAR(255), PRIMARY KEY(id, request_type))</pre><p>And when invoking the Create Request Lambda with a URL we can query said table and see the “Request Created” and shortly after the “Request Processed” events:</p><pre>SELECT * FROM public.requests;</pre><h3>Summary</h3><p>The integration between Aurora DSQL and Kinesis works seamless, and consuming Kinesis with Lambdas is similar. If Aurora DSQL would now also support setting up the CDC Stream via CloudFormation and we wouldn&#39;t have to use the AWS SDK via an AWS Custom Resource for this it would be even better.</p><h3>Appendix</h3><p><em>create-request/src/main.rs</em></p><pre>use aurora_dsql_sqlx_connector::{retry_on_occ, OCCRetryConfig};<br>use lambda_runtime::{run, service_fn, Error, LambdaEvent};<br>use serde::Deserialize;<br>use sqlx::PgPool;<br>use std::env;<br>use uuid::Uuid;<br><br>struct Handler&lt;&#39;a&gt; {<br>    pool: &amp;&#39;a PgPool,<br>}<br><br>#[derive(Deserialize, Debug)]<br>struct CreateRequest {<br>    url: String,<br>}<br><br>impl&lt;&#39;a&gt; Handler&lt;&#39;a&gt; {<br>    async fn handle(&amp;self, lambda_event: LambdaEvent&lt;CreateRequest&gt;) -&gt; Result&lt;(), Error&gt; {<br>        let occ_config = OCCRetryConfig::default();<br>        retry_on_occ(&amp;occ_config, || async {<br>            let mut transaction = self.pool.begin().await?;<br>            sqlx::query(&quot;INSERT INTO public.requests(id, request_type, url) VALUES ($1, $2, $3);&quot;)<br>                .bind(Uuid::new_v4())<br>                .bind(&quot;REQUEST_CREATED&quot;)<br>                .bind(lambda_event.payload.url.clone())<br>                .execute(&amp;mut *transaction)<br>                .await?;<br>            transaction.commit().await?;<br><br>            Ok(())<br>        })<br>        .await?;<br><br>        Ok(())<br>    }<br>}<br><br>#[tokio::main]<br>async fn main() -&gt; Result&lt;(), Error&gt; {<br>    let cluster_endpoint = env::var(&quot;CLUSTER_ENDPOINT&quot;)?;<br>    let connection_string = format!(&quot;postgres://admin@{}/postgres&quot;, cluster_endpoint);<br>    let pool = aurora_dsql_sqlx_connector::pool::connect(connection_string.as_str()).await?;<br>    let handler = Handler { pool: &amp;pool };<br>    run(service_fn(|lambda_event: LambdaEvent&lt;CreateRequest&gt;| {<br>        handler.handle(lambda_event)<br>    }))<br>    .await?;<br>    pool.close().await;<br><br>    Ok(())<br>}</pre><p><em>dispatcher/src/main.rs</em></p><pre>use aws_config::{BehaviorVersion, load_defaults};<br>use aws_lambda_events::kinesis::KinesisEvent;<br>use aws_sdk_sqs::{Client, types::builders::MessageAttributeValueBuilder};<br>use lambda_runtime::{Error, LambdaEvent, run, service_fn};<br>use serde::Deserialize;<br>use std::env;<br><br>struct Handler&lt;&#39;a&gt; {<br>    queue_url: String,<br>    client: &amp;&#39;a Client,<br>}<br><br>#[derive(Deserialize, Debug)]<br>struct After {<br>    id: String,<br>    request_type: String,<br>    url: String,<br>}<br><br>#[derive(Deserialize, Debug)]<br>struct CDCMessage {<br>    op: String,<br>    after: Option&lt;After&gt;,<br>}<br><br>const OPERATION_CREATE: &amp;&#39;static str = &quot;c&quot;;<br><br>impl&lt;&#39;a&gt; Handler&lt;&#39;a&gt; {<br>    async fn handle(&amp;self, lambda_event: LambdaEvent&lt;KinesisEvent&gt;) -&gt; Result&lt;(), Error&gt; {<br>        for kinesis_event_record in lambda_event.payload.records {<br>            let cdc_message: CDCMessage =<br>                serde_json::from_slice(kinesis_event_record.kinesis.data.0.as_slice())?;<br>            let operation = cdc_message.op;<br>            let create = operation == OPERATION_CREATE;<br><br>            if create {<br>                let after = cdc_message.after.unwrap();<br>                let id = uuid::Uuid::parse_str(after.id.as_str()).unwrap();<br>                let request_type = after.request_type;<br>                println!(&quot;Request received: {} ({}).&quot;, request_type, id);<br><br>                match request_type.as_str() {<br>                    &quot;REQUEST_CREATED&quot; =&gt; {<br>                        println!(&quot;Request created.&quot;);<br>                        self.client<br>                            .send_message()<br>                            .queue_url(self.queue_url.clone())<br>                            .message_attributes(<br>                                &quot;id&quot;,<br>                                MessageAttributeValueBuilder::default()<br>                                    .string_value(id)<br>                                    .data_type(&quot;String&quot;.to_string())<br>                                    .build()?,<br>                            )<br>                            .message_body(after.url.as_str())<br>                            .send()<br>                            .await?;<br>                    }<br>                    &quot;REQUEST_PROCESSED&quot; =&gt; {<br>                        println!(&quot;Request processed.&quot;);<br>                    }<br>                    _ =&gt; {<br>                        println!(&quot;Unknown request type: {}&quot;, request_type);<br>                    }<br>                }<br><br>                println!(&quot;Request handled ({}).&quot;, id);<br>            } else {<br>                println!(&quot;Unknown operation: {}&quot;, operation);<br>            }<br>        }<br><br>        Ok(())<br>    }<br>}<br><br>#[tokio::main]<br>async fn main() -&gt; Result&lt;(), Error&gt; {<br>    let queue_url = env::var(&quot;QUEUE_URL&quot;)?;<br>    let sdk_config = load_defaults(BehaviorVersion::latest()).await;<br>    let client = Client::new(&amp;sdk_config);<br>    let handler = Handler {<br>        queue_url,<br>        client: &amp;client,<br>    };<br>    run(service_fn(|lambda_event: LambdaEvent&lt;KinesisEvent&gt;| {<br>        handler.handle(lambda_event)<br>    }))<br>    .await?;<br><br>    Ok(())<br>}</pre><p><em>process-request/src/main.rs</em></p><pre>use aurora_dsql_sqlx_connector::{OCCRetryConfig, retry_on_occ};<br>use aws_lambda_events::sqs::SqsEvent;<br>use lambda_runtime::{Error, LambdaEvent, run, service_fn};<br>use sqlx::PgPool;<br>use std::env;<br><br>struct Handler&lt;&#39;a&gt; {<br>    pool: &amp;&#39;a PgPool,<br>}<br><br>impl&lt;&#39;a&gt; Handler&lt;&#39;a&gt; {<br>    async fn handle(&amp;self, lambda_event: LambdaEvent&lt;SqsEvent&gt;) -&gt; Result&lt;(), Error&gt; {<br>        for sqs_message in lambda_event.payload.records {<br>            let url = sqs_message.body.unwrap();<br>            let id = sqlx::types::Uuid::parse_str(<br>                sqs_message<br>                    .message_attributes<br>                    .get(&quot;id&quot;)<br>                    .unwrap()<br>                    .string_value<br>                    .clone()<br>                    .unwrap()<br>                    .as_str(),<br>            )?;<br>            let status_code = ureq::get(url.as_str()).call()?.status();<br>            let occ_config = OCCRetryConfig::default();<br>            retry_on_occ(&amp;occ_config, || async {<br>                let mut transaction = self.pool.begin().await?;<br>                sqlx::query(&quot;INSERT INTO public.requests(id, request_type, url, status_code) VALUES ($1, $2, $3, $4);&quot;)<br>                    .bind(id)<br>                    .bind(&quot;REQUEST_PROCESSED&quot;)<br>                    .bind(url.clone())<br>                    .bind(status_code.as_str())<br>                    .execute(&amp;mut *transaction)<br>                    .await?;<br>                transaction.commit().await?;<br><br>                Ok(())<br>            }).await?;<br>        }<br><br>        Ok(())<br>    }<br>}<br><br>#[tokio::main]<br>async fn main() -&gt; Result&lt;(), Error&gt; {<br>    let cluster_endpoint = env::var(&quot;CLUSTER_ENDPOINT&quot;)?;<br>    let connection_string = format!(&quot;postgres://admin@{}/postgres&quot;, cluster_endpoint);<br>    let pool = aurora_dsql_sqlx_connector::pool::connect(connection_string.as_str()).await?;<br>    let handler = Handler { pool: &amp;pool };<br>    run(service_fn(|lambda_event: LambdaEvent&lt;SqsEvent&gt;| {<br>        handler.handle(lambda_event)<br>    }))<br>    .await?;<br>    pool.close().await;<br><br>    Ok(())<br>}</pre><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=39c35d7407e5" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AWS Cross-Account Credential Management]]></title>
            <link>https://medium.com/@johannesfloriangeiger/aws-cross-account-credential-management-8c80ff8a8fe2?source=rss-70fbdd10c64f------2</link>
            <guid isPermaLink="false">https://medium.com/p/8c80ff8a8fe2</guid>
            <category><![CDATA[aws-lambda]]></category>
            <category><![CDATA[aws-kms]]></category>
            <category><![CDATA[golang]]></category>
            <category><![CDATA[aws-api-gateway]]></category>
            <category><![CDATA[aws-secrets-manager]]></category>
            <dc:creator><![CDATA[Johannes Geiger]]></dc:creator>
            <pubDate>Sat, 04 Jul 2026 15:19:11 GMT</pubDate>
            <atom:updated>2026-07-04T15:22:37.550Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/471/1*za4I6ClLn7ISa9R_2YnN2g.png" /></figure><p>Access control across AWS accounts is something I’m encountering every now and then and it’s an interesting topic that adds some intricacies to the usual AWS setup. Not always we can rely on clients being able to use IAM auth, especially when operating in abstracted environments such as Kubernetes so the need to share explicit credentials can arise. Not considering a solution where a Third party store like Vault is being used we are going to concentrate on as much AWS native services as possible to facilitate access control. As an example for a service that can be easily used for a proof of concept we will use AWS API Gateway with API Keys, and store those API Keys encrypted with a custom KMS Key in AWS Secrets Manager and we will be looking at scenarios where a Producer account provides an API that a Consumer account wants to access properly.</p><h3>Solution 1: Pull</h3><p>The first solution we are going to look at is the Pull model. In this case, the Producer is responsible for creating the credential store, sharing it with the Consumer and keeping the credentials up to date.</p><h4>Producer</h4><p>We start by creating an API Gateway with a Mock integration that will always return a 200 and require an API key. For this we will use CDK written in Golang:</p><pre>restApi := awsapigateway.NewRestApi(stack, jsii.String(&quot;API&quot;), &amp;awsapigateway.RestApiProps{})<br>restApi.Root().AddMethod(jsii.String(&quot;GET&quot;), awsapigateway.NewMockIntegration(&amp;awsapigateway.IntegrationOptions{<br> IntegrationResponses: &amp;[]*awsapigateway.IntegrationResponse{{<br>  StatusCode: jsii.String(&quot;200&quot;),<br> },<br>},<br> PassthroughBehavior: awsapigateway.PassthroughBehavior_NEVER,<br> RequestTemplates: &amp;map[string]*string{<br>  &quot;application/json&quot;: jsii.String(&quot;{ \&quot;statusCode\&quot;: 200 }&quot;),<br> },<br>}), &amp;awsapigateway.MethodOptions{<br> ApiKeyRequired: jsii.Bool(true),<br> MethodResponses: &amp;[]*awsapigateway.MethodResponse{<br>  {<br>   StatusCode: jsii.String(&quot;200&quot;),<br>  },<br> },<br>})<br><br>awsapigateway.NewDeployment(stack, jsii.String(&quot;Deployment&quot;), &amp;awsapigateway.DeploymentProps{<br> Api: restApi,<br>})</pre><p>even though this API gets deployed, calling it will fail because we’re missing an API Key at the moment which we are adding next:</p><pre>apiKey := awsapigateway.NewApiKey(stack, jsii.String(&quot;APIKey&quot;), &amp;awsapigateway.ApiKeyProps{})<br><br>usagePlan := restApi.AddUsagePlan(jsii.String(&quot;UsagePlan&quot;), &amp;awsapigateway.UsagePlanProps{<br> ApiStages: &amp;[]*awsapigateway.UsagePlanPerApiStage{<br>  {Api: restApi, Stage: restApi.DeploymentStage()},<br> },<br>})<br>usagePlan.AddApiKey(apiKey, &amp;awsapigateway.AddApiKeyOptions{})</pre><p>this will create an API Key and attach it to a Usage Plan which is attached to the API Gateway stage. Calling the API with this API Key should now return the mock result above.</p><p>Next, we will create a KMS Key and a Secret that will hold the API Key in the future:</p><pre>consumerAccount := &quot;ACCOUNT_ID&quot;<br>principalArnWildcard := fmt.Sprintf(&quot;arn:aws:iam::%s:role/ConsumerStack-*&quot;, consumerAccount)<br><br>key := awskms.NewKey(stack, jsii.String(&quot;Key&quot;), &amp;awskms.KeyProps{<br> RemovalPolicy: awscdk.RemovalPolicy_DESTROY,<br>})<br>key.Policy().AddStatements(awsiam.NewPolicyStatement(&amp;awsiam.PolicyStatementProps{<br> Actions: &amp;[]*string{<br>  jsii.String(&quot;kms:Decrypt&quot;),<br> },<br> Conditions: &amp;map[string]interface{}{<br>  &quot;ArnLike&quot;: &amp;map[string]interface{}{<br>   &quot;aws:PrincipalArn&quot;: jsii.String(principalArnWildcard),<br>  },<br> },<br> Principals: &amp;[]awsiam.IPrincipal{<br>  awsiam.NewStarPrincipal(),<br> },<br> Resources: &amp;[]*string{<br>  jsii.String(&quot;*&quot;),<br> },<br>}))<br><br>secret := awssecretsmanager.NewSecret(stack, jsii.String(&quot;Secret&quot;), &amp;awssecretsmanager.SecretProps{<br> EncryptionKey: key,<br>})<br>secret.AddToResourcePolicy(awsiam.NewPolicyStatement(&amp;awsiam.PolicyStatementProps{<br> Actions: &amp;[]*string{<br>  jsii.String(&quot;secretsmanager:GetSecretValue&quot;),<br> },<br> Conditions: &amp;map[string]interface{}{<br>  &quot;ArnLike&quot;: &amp;map[string]interface{}{<br>   &quot;aws:PrincipalArn&quot;: jsii.String(principalArnWildcard),<br>  },<br> },<br> Principals: &amp;[]awsiam.IPrincipal{<br>  awsiam.NewStarPrincipal(),<br> },<br> Resources: &amp;[]*string{<br>  jsii.String(&quot;*&quot;),<br> },<br>}))</pre><p>Here we see the first speciality of cross-account Secret access: The need to explicitly specify the Consumer accounts’ principal that will get access to the Key and the Secret. If the Consumer setup would exist already (in a “dummy” form waiting for the Producer setup to be created) we could use the ARN of the Consumer directly but a more flexible alternative is to use a wildcard Principal and restrict via an IAM Condition which allows the Producer to be created without a Consumer when both have agreed on a pattern of ARN to be used.</p><p>Finally we want to ensure that the API Key is automatically updating the Secret and for this we first create a Synchronize Lambda:</p><pre>synchronizeFunction := awscdklambdagoalpha.NewGoFunction(stack, jsii.String(&quot;Synchronize&quot;), &amp;awscdklambdagoalpha.GoFunctionProps{<br> FunctionName: jsii.String(&quot;Synchronize&quot;),<br> LogGroup: awslogs.NewLogGroup(stack, jsii.String(&quot;SynchronizeLogGroup&quot;), &amp;awslogs.LogGroupProps{<br> LogGroupName:  jsii.String(&quot;SynchronizeLogGroup&quot;),<br>  RemovalPolicy: awscdk.RemovalPolicy_DESTROY,<br> }),<br> Entry: jsii.String(&quot;synchronize&quot;),<br>})</pre><p>ensure it has access to all relevant resources:</p><pre>apiKey.GrantRead(synchronizeFunction)<br>//...<br>key.GrantEncrypt(synchronizeFunction)<br>// ...<br>secret.GrantWrite(synchronizeFunction)</pre><p>and we finally call the Lambda as part of the CDK Stack:</p><pre>synchronizeApiKeyValue := customresources.NewAwsCustomResource(stack, jsii.String(&quot;SynchronizeAPIKeyValue&quot;), &amp;customresources.AwsCustomResourceProps{<br> OnCreate: &amp;customresources.AwsSdkCall{<br>  Service: jsii.String(&quot;Lambda&quot;),<br>  Action:  jsii.String(&quot;invoke&quot;),<br>  Parameters: &amp;map[string]interface{}{<br>   &quot;FunctionName&quot;: jsii.String(&quot;Synchronize&quot;),<br>   &quot;Payload&quot;:      jsii.Sprintf(&quot;{\&quot;apiKeyId\&quot;:\&quot;%s\&quot;,\&quot;secretId\&quot;:\&quot;%s\&quot;}&quot;, *apiKey.KeyId(), *secret.SecretArn()),<br>  },<br>  PhysicalResourceId: customresources.PhysicalResourceId_Of(apiKey.KeyId()),<br> },<br> Policy: customresources.AwsCustomResourcePolicy_FromStatements(&amp;[]awsiam.PolicyStatement{<br>  awsiam.NewPolicyStatement(&amp;awsiam.PolicyStatementProps{<br>   Actions: &amp;[]*string{<br>    jsii.String(&quot;lambda:InvokeFunction&quot;),<br>   },<br>   Resources: &amp;[]*string{<br>    synchronizeFunction.FunctionArn(),<br>   },<br>  }),<br> }),<br>})<br>synchronizeApiKeyValue.Node().AddDependency(synchronizeFunction, apiKey, secret)</pre><p>and afterwards we should be able to verify that the Secret contains indeed the API Key value.</p><h4>Consumer</h4><p>The Consumer then just needs to make sure that the Identity policy of the Lambda has access to the KMS Key and Secret above:</p><pre>keyArn := &quot;KEY_ARN&quot;<br>key := awskms.Key_FromKeyArn(stack, jsii.String(&quot;Key&quot;), jsii.String(keyArn))<br><br>secretArn := &quot;SECRET_ARN&quot;<br>secret := awssecretsmanager.Secret_FromSecretCompleteArn(stack, jsii.String(&quot;Secret&quot;), jsii.String(secretArn))<br><br>url := &quot;API_GATEWAY_URL&quot;<br>function := awscdklambdagoalpha.NewGoFunction(stack, jsii.String(&quot;Consumer&quot;), &amp;awscdklambdagoalpha.GoFunctionProps{<br> Environment: &amp;map[string]*string{<br>  &quot;SECRET_ID&quot;: jsii.String(secretArn),<br>  &quot;URL&quot;:       jsii.String(url),<br> },<br> FunctionName: jsii.String(&quot;Consumer&quot;),<br> LogGroup: awslogs.NewLogGroup(stack, jsii.String(&quot;ConsumerLogGroup&quot;), &amp;awslogs.LogGroupProps{<br>  LogGroupName:  jsii.String(&quot;ConsumerLogGroup&quot;),<br>  RemovalPolicy: awscdk.RemovalPolicy_DESTROY,<br> }),<br> Entry: jsii.String(&quot;consumer&quot;),<br>})<br>key.GrantDecrypt(function)<br>secret.GrantRead(function, nil)</pre><p>and invoking the Consumer then should lead to a 200 OK being returned to us.</p><h3>Solution 2: Push</h3><p>The second solution, the Push model, will invert the roles a bit. In this case, the Consumer is responsible for creating the credential store, sharing it with the Producer and the Producer will keep the credentials up to date.</p><h4>Consumer</h4><p>We first start by setting up the Consumer:</p><pre>producerAccount := &quot;ACCOUNT_ID&quot;<br>principalArnWildcard := fmt.Sprintf(&quot;arn:aws:iam::%s:role/ProducerStack-*&quot;, producerAccount)<br><br>key := awskms.NewKey(stack, jsii.String(&quot;Key&quot;), &amp;awskms.KeyProps{<br> RemovalPolicy: awscdk.RemovalPolicy_DESTROY,<br>})<br>key.Policy().AddStatements(awsiam.NewPolicyStatement(&amp;awsiam.PolicyStatementProps{<br> Actions: &amp;[]*string{<br>  jsii.String(&quot;kms:GenerateDataKey&quot;),<br>  jsii.String(&quot;kms:Encrypt&quot;),<br> },<br> Conditions: &amp;map[string]interface{}{<br>  &quot;ArnLike&quot;: &amp;map[string]interface{}{<br>   &quot;aws:PrincipalArn&quot;: jsii.String(principalArnWildcard),<br>  },<br> },<br> Principals: &amp;[]awsiam.IPrincipal{<br>  awsiam.NewStarPrincipal(),<br> },<br> Resources: &amp;[]*string{<br>  jsii.String(&quot;*&quot;),<br> },<br>}))<br><br>secret := awssecretsmanager.NewSecret(stack, jsii.String(&quot;Secret&quot;), &amp;awssecretsmanager.SecretProps{<br> EncryptionKey: key,<br>})<br>secret.AddToResourcePolicy(awsiam.NewPolicyStatement(&amp;awsiam.PolicyStatementProps{<br> Actions: &amp;[]*string{<br>  jsii.String(&quot;secretsmanager:PutSecretValue&quot;),<br> },<br> Conditions: &amp;map[string]interface{}{<br>  &quot;ArnLike&quot;: &amp;map[string]interface{}{<br>   &quot;aws:PrincipalArn&quot;: jsii.String(principalArnWildcard),<br>  },<br> },<br> Principals: &amp;[]awsiam.IPrincipal{<br>  awsiam.NewStarPrincipal(),<br> },<br> Resources: &amp;[]*string{<br>  jsii.String(&quot;*&quot;),<br> },<br>}))<br><br>url := &quot;&quot;<br>consumer := awscdklambdagoalpha.NewGoFunction(stack, jsii.String(&quot;Consumer&quot;), &amp;awscdklambdagoalpha.GoFunctionProps{<br> Environment: &amp;map[string]*string{<br>  &quot;SECRET_ID&quot;: secret.SecretArn(),<br>  &quot;URL&quot;:       jsii.String(url),<br> },<br> FunctionName: jsii.String(&quot;Consumer&quot;),<br> LogGroup: awslogs.NewLogGroup(stack, jsii.String(&quot;ConsumerLogGroup&quot;), &amp;awslogs.LogGroupProps{<br>  LogGroupName:  jsii.String(&quot;ConsumerLogGroup&quot;),<br>  RemovalPolicy: awscdk.RemovalPolicy_DESTROY,<br> }),<br> Entry: jsii.String(&quot;consumer&quot;),<br>})<br>key.GrantDecrypt(consumer)<br>secret.GrantRead(consumer, nil)</pre><p>The overall logic with regards to Wildcard Principals is similar to above, the KMS Key and the Secret are set up with an agreed pattern of how the IAM Roles in the Producer stack will be called and the Consumer Lambda does not yet refer to the “real” URL as it doesn’t exist yet.</p><h4>Producer</h4><p>The Producer looks similar to the last scenario:</p><pre>restApi := awsapigateway.NewRestApi(stack, jsii.String(&quot;API&quot;), &amp;awsapigateway.RestApiProps{})<br>restApi.Root().AddMethod(jsii.String(&quot;GET&quot;), awsapigateway.NewMockIntegration(&amp;awsapigateway.IntegrationOptions{<br> IntegrationResponses: &amp;[]*awsapigateway.IntegrationResponse{{<br>  StatusCode: jsii.String(&quot;200&quot;),<br> },<br> },<br> PassthroughBehavior: awsapigateway.PassthroughBehavior_NEVER,<br> RequestTemplates: &amp;map[string]*string{<br>  &quot;application/json&quot;: jsii.String(&quot;{ \&quot;statusCode\&quot;: 200 }&quot;),<br> },<br>}), &amp;awsapigateway.MethodOptions{<br> ApiKeyRequired: jsii.Bool(true),<br> MethodResponses: &amp;[]*awsapigateway.MethodResponse{<br>  {<br>   StatusCode: jsii.String(&quot;200&quot;),<br>  },<br> },<br>})<br><br>awsapigateway.NewDeployment(stack, jsii.String(&quot;Deployment&quot;), &amp;awsapigateway.DeploymentProps{<br> Api: restApi,<br>})<br><br>keyArn := &quot;KEY_ARN&quot;<br>secretArn := &quot;SECRET_ARN&quot;<br>synchronizeFunction := awscdklambdagoalpha.NewGoFunction(stack, jsii.String(&quot;Synchronize&quot;), &amp;awscdklambdagoalpha.GoFunctionProps{<br> FunctionName: jsii.String(&quot;Synchronize&quot;),<br> LogGroup: awslogs.NewLogGroup(stack, jsii.String(&quot;SynchronizeLogGroup&quot;), &amp;awslogs.LogGroupProps{<br>  LogGroupName:  jsii.String(&quot;SynchronizeLogGroup&quot;),<br>  RemovalPolicy: awscdk.RemovalPolicy_DESTROY,<br> }),<br> Entry: jsii.String(&quot;synchronize&quot;),<br>})<br>synchronizeFunction.Role().AttachInlinePolicy(awsiam.NewPolicy(stack, jsii.String(&quot;SynchronizeRolePolicy&quot;), &amp;awsiam.PolicyProps{<br> Statements: &amp;[]awsiam.PolicyStatement{<br>  awsiam.NewPolicyStatement(&amp;awsiam.PolicyStatementProps{<br>   Actions: &amp;[]*string{<br>    jsii.String(&quot;kms:GenerateDataKey&quot;),<br>    jsii.String(&quot;kms:Encrypt&quot;),<br>   },<br>   Resources: &amp;[]*string{<br>    jsii.String(keyArn),<br>   },<br>  }),<br>  awsiam.NewPolicyStatement(&amp;awsiam.PolicyStatementProps{<br>   Actions: &amp;[]*string{<br>    jsii.String(&quot;secretsmanager:PutSecretValue&quot;),<br>   },<br>   Resources: &amp;[]*string{<br>    jsii.String(secretArn),<br>   },<br>  }),<br> },<br>}))<br><br>apiKey := awsapigateway.NewApiKey(stack, jsii.String(&quot;APIKey&quot;), &amp;awsapigateway.ApiKeyProps{})<br>apiKey.GrantRead(synchronizeFunction)<br><br>usagePlan := restApi.AddUsagePlan(jsii.String(&quot;UsagePlan&quot;), &amp;awsapigateway.UsagePlanProps{<br> ApiStages: &amp;[]*awsapigateway.UsagePlanPerApiStage{<br>  {Api: restApi, Stage: restApi.DeploymentStage()},<br> },<br>})<br>usagePlan.AddApiKey(apiKey, &amp;awsapigateway.AddApiKeyOptions{})<br><br>synchronizeApiKeyValue := customresources.NewAwsCustomResource(stack, jsii.String(&quot;SynchronizeAPIKeyValue&quot;), &amp;customresources.AwsCustomResourceProps{<br> OnCreate: &amp;customresources.AwsSdkCall{<br>  Service: jsii.String(&quot;Lambda&quot;),<br>  Action:  jsii.String(&quot;invoke&quot;),<br>  Parameters: &amp;map[string]interface{}{<br>   &quot;FunctionName&quot;: jsii.String(&quot;Synchronize&quot;),<br>   &quot;Payload&quot;:      jsii.Sprintf(&quot;{\&quot;apiKeyId\&quot;:\&quot;%s\&quot;,\&quot;secretId\&quot;:\&quot;%s\&quot;}&quot;, *apiKey.KeyId(), secretArn),<br>  },<br>  PhysicalResourceId: customresources.PhysicalResourceId_Of(apiKey.KeyId()),<br> },<br> Policy: customresources.AwsCustomResourcePolicy_FromStatements(&amp;[]awsiam.PolicyStatement{<br>  awsiam.NewPolicyStatement(&amp;awsiam.PolicyStatementProps{<br>   Actions: &amp;[]*string{<br>    jsii.String(&quot;lambda:InvokeFunction&quot;),<br>   },<br>   Resources: &amp;[]*string{<br>    synchronizeFunction.FunctionArn(),<br>   },<br>  }),<br> }),<br>})<br>synchronizeApiKeyValue.Node().AddDependency(synchronizeFunction, apiKey)</pre><p>with the main difference that the KMS Key and Secret ARN are now coming from the Consumer stack. Other than that, the Synchronize Lambda again takes care of writing the API Key value to the Secret and after a deployment the Consumer Secret should contain the correct value.</p><h4>Consumer</h4><p>Finally, changing the Consumer stack by adding the newly created URL of the API Gateway allows us to invoke the Consumer and get the same 200 OK as above.</p><h3>Summary</h3><p>Both Pull and Push models work similar, accessing Secrets across account boundaries works fine. The decision where to eventually place the Secret is not clear cut as in both scenarios the stacks require knowledge of the respective other account and of course, Pull and Push could also even be combined by having a dedicated Credential account that is being written to by the Producer and read from by the Consumer. We haven’t covered the topic of notifying the Consumer on Secret changes but that could be easily done as part of the Synchronize Lambda, e.g. by sending a message to an SNS topic or similar.</p><h3>Appendix</h3><h4>Synchronize</h4><pre>package main<br><br>import (<br> &quot;context&quot;<br><br> &quot;github.com/aws/aws-lambda-go/lambda&quot;<br> &quot;github.com/aws/aws-sdk-go-v2/aws&quot;<br> &quot;github.com/aws/aws-sdk-go-v2/config&quot;<br> &quot;github.com/aws/aws-sdk-go-v2/service/apigateway&quot;<br> &quot;github.com/aws/aws-sdk-go-v2/service/secretsmanager&quot;<br> &quot;github.com/aws/jsii-runtime-go&quot;<br>)<br><br>type Request struct {<br> ApiKeyId string `json:&quot;apiKeyId&quot;`<br> SecretId string `json:&quot;secretId&quot;`<br>}<br><br>type Handler struct {<br> ApiGatewayClient     *apigateway.Client<br> SecretsManagerClient *secretsmanager.Client<br>}<br><br>func (handler *Handler) handleRequest(ctx context.Context, event Request) error {<br> getApiKeyOutput, err := handler.ApiGatewayClient.GetApiKey(ctx, &amp;apigateway.GetApiKeyInput{<br>  ApiKey:       aws.String(event.ApiKeyId),<br>  IncludeValue: jsii.Bool(true),<br> })<br> if err != nil {<br>  return err<br> }<br><br> _, err = handler.SecretsManagerClient.PutSecretValue(ctx, &amp;secretsmanager.PutSecretValueInput{<br>  SecretId:     aws.String(event.SecretId),<br>  SecretString: aws.String(*getApiKeyOutput.Value),<br> })<br> if err != nil {<br>  return err<br> }<br><br> return nil<br>}<br><br>func main() {<br> cfg, err := config.LoadDefaultConfig(context.TODO())<br> if err != nil {<br>  panic(err)<br> }<br><br> apiGatewayClient := apigateway.NewFromConfig(cfg)<br> secretsManagerClient := secretsmanager.NewFromConfig(cfg)<br><br> handler := Handler{<br>  ApiGatewayClient:     apiGatewayClient,<br>  SecretsManagerClient: secretsManagerClient,<br> }<br><br> lambda.Start(handler.handleRequest)<br>}</pre><h4>Consumer</h4><pre>package main<br><br>import (<br> &quot;context&quot;<br> &quot;io&quot;<br> &quot;net/http&quot;<br> &quot;net/url&quot;<br> &quot;os&quot;<br><br> &quot;github.com/aws/aws-lambda-go/lambda&quot;<br> &quot;github.com/aws/aws-sdk-go-v2/config&quot;<br> &quot;github.com/aws/aws-sdk-go-v2/service/secretsmanager&quot;<br> &quot;github.com/aws/jsii-runtime-go&quot;<br>)<br><br>type Handler struct {<br> SecretId string<br> Client   *secretsmanager.Client<br> Url      string<br>}<br><br>func (handler *Handler) handleRequest(ctx context.Context) (string, error) {<br> getSecretValueOutput, err := handler.Client.GetSecretValue(ctx, &amp;secretsmanager.GetSecretValueInput{<br>  SecretId: jsii.String(handler.SecretId),<br> })<br> if err != nil {<br>  return &quot;&quot;, err<br> }<br><br> client := http.Client{}<br><br> parsedUrl, err := url.Parse(handler.Url)<br> if err != nil {<br>  return &quot;&quot;, err<br> }<br><br> request := http.Request{<br>  Method: &quot;GET&quot;,<br>  URL:    parsedUrl,<br>  Header: map[string][]string{<br>   &quot;x-api-key&quot;: {*getSecretValueOutput.SecretString},<br>  },<br> }<br> response, err := client.Do(&amp;request)<br> if err != nil {<br>  return &quot;&quot;, err<br> }<br> defer func(Body io.ReadCloser) {<br>  err := Body.Close()<br>  if err != nil {<br>  }<br> }(response.Body)<br><br> return response.Status, nil<br>}<br><br>func main() {<br> cfg, err := config.LoadDefaultConfig(context.TODO())<br> if err != nil {<br>  panic(err)<br> }<br><br> client := secretsmanager.NewFromConfig(cfg)<br><br> handler := Handler{<br>  SecretId: os.Getenv(&quot;SECRET_ID&quot;),<br>  Client:   client,<br>  Url:      os.Getenv(&quot;URL&quot;),<br> }<br><br> lambda.Start(handler.handleRequest)<br>}</pre><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=8c80ff8a8fe2" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AWS Workload Credentials Provider for AWS Lambdas, hands-on]]></title>
            <link>https://medium.com/@johannesfloriangeiger/aws-workload-credentials-provider-for-aws-lambdas-hands-on-ae436604b573?source=rss-70fbdd10c64f------2</link>
            <guid isPermaLink="false">https://medium.com/p/ae436604b573</guid>
            <category><![CDATA[aws-cdk]]></category>
            <category><![CDATA[aws-lambda]]></category>
            <category><![CDATA[rust]]></category>
            <category><![CDATA[aws-secrets-manager]]></category>
            <category><![CDATA[typescript]]></category>
            <dc:creator><![CDATA[Johannes Geiger]]></dc:creator>
            <pubDate>Sat, 20 Jun 2026 21:47:19 GMT</pubDate>
            <atom:updated>2026-06-20T21:47:19.705Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/345/1*RrdCzff6mJTZj77b6uhFQw.png" /></figure><p><a href="https://aws.amazon.com/about-aws/whats-new/2026/06/aws-workload-credentials-provider/">On June 11, 2026 AWS announced the AWS Workload Credentials Provider</a> (AWS WCP), a client-side application that provides unified access to AWS services such as SecretsManager and ACM for various Serverless deployment models (EC2, ECS, Lambda) so why not try that with AWS Lambdas? In addition to being a facade for SecretsManager, the AWS WCP caches Secrets and allows users to pre-fetch Secrets and also to use a specific IAM role that differs from the Lambdas’ role to read those Secrets.</p><h3>Introduction</h3><p>In the following, we will be looking at Setup, Programming Model and Runtime Characteristics of four different examples: <em>Direct</em> (loading secrets with the AWS SDK in a Lambda the usual way), <em>Indirect</em> (loading secrets via the workload credentials provider), <em>IndirectPreFetch</em> (loading secrets via the workload credentials provider with pre-fetching them on startup) and <em>IndirectPreFetchAssumeRole</em> (loading secrets via the workload credentials provider with pre-fetching them on startup using an IAM role separate from the Lambda IAM role).</p><p>All four Lambdas invoke a REST API that requires an API Key stored in a Secret, obtained with lazy loading and stored within the Lambdas’ memory for subsequent invocations. The Lambdas further handle the error case of receiving a HTTP 403 Forbidden from the API when the API Key has been “rotated” by re-loading the secret on demand before trying the request one more time to compare whether that behaviour has an effect on the Programming Model.</p><h3>Setup</h3><p>All four Lambdas are setup with CDK, the actual Lambda code is added further below.</p><h4>Direct</h4><p>The setup for the <em>Direct</em> Lambda is straightforward:</p><pre>const directLambda = new NodejsFunction(this, &#39;DirectLambda&#39;, {<br>    environment: {<br>        &#39;SECRET_ARN&#39;: secret.secretArn,<br>    },<br>    functionName: &#39;Direct&#39;,<br>    logGroup: new LogGroup(this, &#39;DirectLogGroup&#39;, {<br>        logGroupName: &#39;DirectLogGroup&#39;,<br>        removalPolicy: RemovalPolicy.DESTROY,<br>    }),<br>    entry: &#39;./direct/index.ts&#39;,<br>    handler: &#39;handler&#39;<br>});<br>secret.grantRead(directLambda);</pre><p>No surprises here. A Lambda, the ARN of the Secret is passed in, we’re using a CDK Construct to build the Lambda from TypeScript and grant read access to the Secret.</p><h4>Indirect</h4><p>To setup the <em>Indirect</em> Lambda we first need to build the AWS Workload Credentials Provider, see the instructions at <a href="https://github.com/aws/aws-workload-credentials-provider/tree/main">https://github.com/aws/aws-workload-credentials-provider/tree/main</a>. As I am on a MacBook I had been using <a href="https://github.com/cross-rs/cross">cross</a> to cross-compile for Linux, and needed the following as Cross.toml due to a gcc issue:</p><pre>[target.x86_64-unknown-linux-gnu]<br>image = &quot;ghcr.io/cross-rs/x86_64-unknown-linux-gnu:edge&quot;<br>pre-build = [<br>    &#39;apt-get update &amp;&amp; apt-get -y upgrade&#39;,<br>    # Need to upgrade gcc to a version that does have a bug (because aws-lc-sys&#39;s custom build script errors with this:<br>    #  ### COMPILER BUG DETECTED ###<br>    #  Your compiler (cc) is not supported due to a memcmp related bug reported in https://gcc.gnu.org/bugzilla/show_bug.cgi?id=95189. We strongly recommend against using this compiler.<br>    &#39;apt-get -y install gcc-10 &amp;&amp; update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-10 60&#39;,<br>]</pre><p>afterwards, build it with</p><pre>cross build --release --target=x86_64-unknown-linux-gnu</pre><p>and we can follow the instructions for AWS Lambda including creating a Lambda Layer containing the extension.</p><p>The setup itself is then relatively straightforward again:</p><pre>const layerVersion = LayerVersion.fromLayerVersionArn(this, &#39;LayerVersion&#39;, &#39;&lt;LAYER-ARN&gt;&#39;);<br><br>const indirectLambda = new NodejsFunction(this, &#39;IndirectLambda&#39;, {<br>    environment: {<br>        &#39;SECRET_ID&#39;: secret.secretName,<br>    },<br>    functionName: &#39;Indirect&#39;,<br>    memorySize: 1024,<br>    layers: [<br>        layerVersion<br>    ],<br>    logGroup: new LogGroup(this, &#39;IndirectLogGroup&#39;, {<br>        logGroupName: &#39;IndirectLogGroup&#39;,<br>        removalPolicy: RemovalPolicy.DESTROY,<br>    }),<br>    entry: &#39;./indirect/index.ts&#39;,<br>    handler: &#39;handler&#39;<br>});<br>secret.grantRead(indirectLambda);</pre><h4>IndirectPreFetch</h4><p>If we want to make use of the pre-fetching functionality we need to adjust the Extension configuration:</p><p>Putting the following into a config.toml file</p><pre>[[capabilities.secrets_manager.prefetch.secrets]]<br>secret_id = &quot;&lt;SECRET-ARN&gt;&quot;</pre><p>adjusting the Extensions’ shell script to use it</p><pre>/opt/bin/aws-workload-credentials-provider sm start --config /opt/bin/config.toml &amp;</pre><p>and making sure the config file ends up in the Lambda Layer should do it.</p><p>The main difference to the <em>Indirect</em> scenario is that the Lambda role needs the permission secretsmanager:BatchGetSecretValue :</p><pre>const indirectLambdaPreFetch = new NodejsFunction(this, &#39;IndirectLambdaPreFetch&#39;, {<br>    environment: {<br>        &#39;SECRET_ID&#39;: &#39;&lt;SECRET-ID&gt;&#39;,<br>    },<br>    functionName: &#39;IndirectPreFetch&#39;,<br>    memorySize: 1024,<br>    layers: [<br>        LayerVersion.fromLayerVersionArn(this, &#39;LayerVersionPreFetch&#39;, &#39;&lt;LAYER-ARN&gt;&#39;)<br>    ],<br>    logGroup: new LogGroup(this, &#39;IndirectPreFetchLogGroup&#39;, {<br>        logGroupName: &#39;IndirectPreFetchLogGroup&#39;,<br>        removalPolicy: RemovalPolicy.DESTROY,<br>    }),<br>    entry: &#39;./indirect/index.ts&#39;,<br>    handler: &#39;handler&#39;<br>});<br>indirectLambdaPreFetch.role!.attachInlinePolicy(new Policy(this, &#39;IndirectLambdaPreFetchRole&#39;, {<br>    statements: [new PolicyStatement({<br>        actions: [&#39;secretsmanager:DescribeSecret&#39;, &#39;secretsmanager:GetSecretValue&#39;, &#39;secretsmanager:BatchGetSecretValue&#39;],<br>        resources: [&#39;&lt;SECRET-ARN&gt;&#39;]<br>    })]<br>}))</pre><p>The rest is identical with the <em>Indirect</em> scenario. Note that the Lambda role still needs to be able to read the Secret as before, as the Extension runs with the role of the Lambda.</p><h4>IndirectPreFetchAssumeRole</h4><p>Similar to the previous scenario, we need a config file:</p><pre>[[capabilities.secrets_manager.prefetch.secrets]]<br>secret_id = &quot;&lt;SECRET-ARN&gt;&quot;<br>role_arn = &quot;&lt;ROLE-ARN&gt;&quot;</pre><p>This time with a Secret and a Role.</p><p>Again as the previous scenario, the base setup is similar, the permissions change though cause the Lambda no longer needs to be able to read the Secret but to assume the role:</p><pre>const secretRoleArn = &#39;&lt;SECRET-ROLE-ARN&gt;&#39;;<br>const indirectLambdaPreFetchAssumeRole = new NodejsFunction(this, &#39;IndirectLambdaPreFetchAssumeRole&#39;, {<br>    environment: {<br>        &#39;SECRET_ID&#39;: &#39;&lt;SECRET-ID&gt;&#39;,<br>        &#39;ROLE_ARN&#39;: secretRoleArn<br>    },<br>    functionName: &#39;IndirectPreFetchAssumeRole&#39;,<br>    memorySize: 1024,<br>    layers: [<br>        LayerVersion.fromLayerVersionArn(this, &#39;LayerVersionPreFetchAssumeRole&#39;, &#39;&lt;LAYER-ARN&gt;&#39;)<br>    ],<br>    logGroup: new LogGroup(this, &#39;IndirectPreFetchAssumeRoleLogGroup&#39;, {<br>        logGroupName: &#39;IndirectPreFetchAssumeRoleLogGroup&#39;,<br>        removalPolicy: RemovalPolicy.DESTROY,<br>    }),<br>    entry: &#39;./indirect/index.ts&#39;,<br>    handler: &#39;handler&#39;<br>});<br>indirectLambda1024PreFetchAssumeRole.role!.attachInlinePolicy(new Policy(this, &#39;IndirectLambdaPreFetchAssumeRoleRole&#39;, {<br>    statements: [<br>        new PolicyStatement({<br>            actions: [&#39;sts:AssumeRole&#39;],<br>            resources: [secretRoleArn]<br>        })<br>    ]<br>}))</pre><h3>Programming Model</h3><p>To look at the differences in the Programming Models lets look at the <em>Direct</em> scenario first:</p><pre>async function ensureSecretLoaded() {<br>    if (secretsManagerClient === undefined) {<br>        secretsManagerClient = new SecretsManagerClient();<br>    }<br><br>    if (secretValue === undefined) {<br>        const getSecretValueCommand = new GetSecretValueCommand({<br>            SecretId: secretArn<br>        });<br>        const getSecretValueCommandOutput = await secretsManagerClient.send(getSecretValueCommand);<br>        secretValue = getSecretValueCommandOutput.SecretString!<br>    }<br><br>    return secretValue;<br>}</pre><p>Here we initialize the Secrets Manager on demand and the Secret afterwards. If the request fails and we need to enforce a reload of the Secret it’s sufficient to set the secretValue to undefined and invoke ensureSecretLoaded again to reload it.</p><p>In the <em>Indirect</em> scenarios reloading the Secret doesn’t require invalidation and instead we force a reload via the refreshNow parameter:</p><pre>async function ensureSecretLoaded(refreshNow: boolean) {<br>    if (refreshNow || secretValue === undefined) {<br>        const response = await fetch(`http://localhost:2773/secretsmanager/get?secretId=${secretId}${roleArn === undefined ? &#39;&#39; : `&amp;roleArn=${roleArn}`}&amp;refreshNow=${refreshNow}`, {<br>            headers: {<br>                &#39;X-Aws-Parameters-Secrets-Token&#39;: awsSessionToken<br>            }<br>        });<br><br>        secretValue = (await response.json() as GetSecretValueResponse).SecretString;<br>    }<br><br>    return secretValue;<br>}</pre><p>Here we initialize secretValue either if forced via the flag or if it’s undefined on demand, if forced we ask the Extension to reload it to make sure it’s no longer stale in the Extensions’ cache.</p><h3>Runtime Characteristics</h3><p>The first big difference between <em>Direct</em> and the <em>Indirect</em> scenarios is that <em>Direct</em> can run with minimal settings (128MB, 3s timeout) whilst the <em>Indirect</em> scenarios won’t even succeed in orderly fashion with that, and instead error. Using the learnings from <a href="https://medium.com/@jconning/aws-lambda-faster-is-cheaper-6bf32f58d741">https://medium.com/@jconning/aws-lambda-faster-is-cheaper-6bf32f58d741</a> and <a href="https://dev.to/neverendingqs/aws-lambda-always-allocate-at-least-1024-mb-349k?comments_sort=oldest">https://dev.to/neverendingqs/aws-lambda-always-allocate-at-least-1024-mb-349k</a> I eventually used 1024MB memory as the default for all scenarios (see the CDK setup above).</p><p>When it comes to the comparison of the Cold and Warm starts between <em>Direct</em>, <em>Indirect</em>, <em>IndirectPreFetch</em> and <em>IndirectPreFetchAssumeRole</em> the tests have shown that these four scenarios are roughly splitting into two groups: The <em>Direct</em> scenario requires ~100MB memory, ~500ms for Cold starts and ~30–40ms for Warm starts. The <em>Indirect</em> scenarios require ~120–140MB memory, ~770–810ms for Cold starts and ~60–80ms for Warm starts. I would assume the higher memory usage and start times for the <em>Indirect</em> Scenarios originate in the Lambda Layer extension running alongside the Lambda although the differences in absolute numbers, especially compared to the <em>Direct</em> scenario, aren’t gigantic.</p><h3>Summary</h3><p>Whilst the setup of the AWS Workload Credentials Provider is not very complex, the fact that for the scenarios <em>PreFetch</em> and <em>PreFetchAssumeRole</em> the configuration needs to be baked into the Lambda Layer makes it not very flexible, the Programming Model is very similar and comes without any surprises and the Runtime Characteristics are also acceptable.</p><p>Personally the overhead of having to include the config for pre-fetching and assuming of roles wouldn’t outweigh the benefits of a unified interface for loading Secrets but if that will be supported via e.g. environment variables in the future I would be more than happy.</p><h3>Appendix</h3><h4>direct/index.ts</h4><pre>import {GetSecretValueCommand, SecretsManagerClient} from &#39;@aws-sdk/client-secrets-manager&#39;<br><br>const secretArn = process.env.SECRET_ARN!;<br><br>let secretsManagerClient: SecretsManagerClient | undefined = undefined;<br>let secretValue: string | undefined;<br><br>function invalidateSecret() {<br>    secretValue = undefined;<br>}<br><br>async function ensureSecretLoaded() {<br>    if (secretsManagerClient === undefined) {<br>        secretsManagerClient = new SecretsManagerClient();<br>    }<br><br>    if (secretValue === undefined) {<br>        const getSecretValueCommand = new GetSecretValueCommand({<br>            SecretId: secretArn<br>        });<br>        const getSecretValueCommandOutput = await secretsManagerClient.send(getSecretValueCommand);<br>        secretValue = getSecretValueCommandOutput.SecretString!<br>    }<br><br>    return secretValue;<br>}<br><br>async function performRequest(url: string) {<br>    return await fetch(url, {<br>        headers: {<br>            &#39;x-api-key&#39;: await ensureSecretLoaded(),<br>        }<br>    })<br>}<br><br>exports.handler = async (event: { url: string }) =&gt; {<br>    const response = await performRequest(event.url);<br><br>    if (response.status === 403) {<br>        invalidateSecret();<br>        const response = await performRequest(event.url);<br><br>        return response.status;<br>    } else {<br>        return response.status;<br>    }<br>}</pre><h4>indirect/index.ts</h4><pre>const awsSessionToken = process.env.AWS_SESSION_TOKEN!<br>const secretId = process.env.SECRET_ID!;<br>const roleArn = process.env.ROLE_ARN;<br><br>interface GetSecretValueResponse {<br>    SecretString: string;<br>}<br><br>let secretValue: string | undefined;<br><br>async function ensureSecretLoaded(refreshNow: boolean) {<br>    if (refreshNow || secretValue === undefined) {<br>        const response = await fetch(`http://localhost:2773/secretsmanager/get?secretId=${secretId}${roleArn === undefined ? &#39;&#39; : `&amp;roleArn=${roleArn}`}&amp;refreshNow=${refreshNow}`, {<br>            headers: {<br>                &#39;X-Aws-Parameters-Secrets-Token&#39;: awsSessionToken<br>            }<br>        });<br><br>        secretValue = (await response.json() as GetSecretValueResponse).SecretString;<br>    }<br><br>    return secretValue;<br>}<br><br>async function performRequest(url: string, refreshNow: boolean) {<br>    return await fetch(url, {<br>        headers: {<br>            &#39;x-api-key&#39;: await ensureSecretLoaded(refreshNow),<br>        }<br>    })<br>}<br><br>exports.handler = async (event: { url: string }) =&gt; {<br>    const response = await performRequest(event.url, false);<br><br>    if (response.status === 403) {<br>        const response = await performRequest(event.url, true);<br><br>        return response.status;<br>    } else {<br>        return response.status;<br>    }<br>}</pre><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ae436604b573" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>