<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Paul SANTUS</title>
    <description>The latest articles on DEV Community by Paul SANTUS (@psantus).</description>
    <link>https://dev.to/psantus</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1338515%2F8abdaf33-0c48-4f84-aa29-7a881090986e.jpeg</url>
      <title>DEV Community: Paul SANTUS</title>
      <link>https://dev.to/psantus</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/psantus"/>
    <language>en</language>
    <item>
      <title>The Flying Squirrel: when the SPIP CMS goes serverless</title>
      <dc:creator>Paul SANTUS</dc:creator>
      <pubDate>Tue, 08 Sep 2026 21:51:57 +0000</pubDate>
      <link>https://dev.to/aws-builders/the-flying-squirrel-when-the-spip-cms-goes-serverless-3ihp</link>
      <guid>https://dev.to/aws-builders/the-flying-squirrel-when-the-spip-cms-goes-serverless-3ihp</guid>
      <description>&lt;h2&gt;
  
  
  In the beginning..
&lt;/h2&gt;

&lt;p&gt;The French Web was formless and empty.. a handful of sites (academic ones especially), tied to one another by links and listed in directories.&lt;/p&gt;

&lt;p&gt;1996: the first web hosting services, Mygale, then Chez.com. "&lt;em&gt;There was evening, and there was morning. The first day.&lt;/em&gt;"&lt;/p&gt;

&lt;p&gt;1999: As a teenager, I learn PHP. "&lt;em&gt;There was evening, and there was morning. The second day.&lt;/em&gt;"&lt;/p&gt;

&lt;p&gt;2000: The first CMS give non-developers the power to become editors. Drupal, then SPIP. And MySQL databases. Not much content to publish on my side, but I read a lot. I watch sites (many left-wing activist ones like uZine, rezo.net..) pick up the tool. "&lt;em&gt;There was evening, and there was morning. The third day.&lt;/em&gt;"&lt;/p&gt;

&lt;p&gt;2003: WordPress and Dotclear. So easy to write without developing; I set dev aside for a while. "&lt;em&gt;There was evening, and there was morning. The fourth day.&lt;/em&gt;"&lt;/p&gt;

&lt;p&gt;2016: thanks to a few pioneers, I discover the cloud. I am blown away by AWS's ability to summon infrastructure on demand, and by what it lets me offer: highly available, scalable applications. "&lt;em&gt;There was evening, and there was morning...&lt;/em&gt;"&lt;/p&gt;

&lt;h2&gt;
  
  
  And then, all of a sudden.
&lt;/h2&gt;

&lt;p&gt;May 2026, a phone call. "&lt;em&gt;Paul, I want to build an app. But I have one non-negotiable requirement: the backend must run on SPIP.&lt;/em&gt;"&lt;/p&gt;

&lt;p&gt;As often on the projects I support: no infra skills available. And the ambition to ship a consumer-facing application (more on that soon).&lt;/p&gt;

&lt;p&gt;With the CMS imposed, how do I:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;run it without paying around the clock (including at 3am when there is not a single visitor)?&lt;/li&gt;
&lt;li&gt;without maintaining servers?&lt;/li&gt;
&lt;li&gt;while keeping up with the load when our app takes off?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Serverless, of course. It remained to be seen whether it would actually work.&lt;/p&gt;

&lt;h2&gt;
  
  
  On the sixth day, Paul created SPIP-serverless
&lt;/h2&gt;

&lt;p&gt;I discover SPIP, a French free-software CMS, written in PHP and maintained by its community for 26 years. The project's architecture is not exactly modern.. and above all, it is designed to be simple to administer, for non-technical people, to be deployed on a single machine rather than in a distributed compute environment built for high availability: no session handling in a shared cache, in-place upgrades, and so on.&lt;/p&gt;

&lt;p&gt;With its need for a persistent local disk and an always-on database, it was not the natural candidate for serverless.&lt;/p&gt;

&lt;p&gt;And yet, I am proud to announce today &lt;strong&gt;spip-serverless&lt;/strong&gt;, a port of SPIP onto a 100% serverless architecture. You can put away your shared hosting :)&lt;/p&gt;

&lt;p&gt;The project is &lt;a href="https://github.com/psantus/spip-serverless" rel="noopener noreferrer"&gt;available on GitHub&lt;/a&gt;, under the MIT license. And you can see it in action &lt;a href="https://d3l8qij0w6ijg3.cloudfront.net/" rel="noopener noreferrer"&gt;on this demo site&lt;/a&gt;. It will likely be many months before this site costs me a single euro in hosting.&lt;/p&gt;

&lt;h2&gt;
  
  
  SPIP-serverless: how does it work? The main architecture choices
&lt;/h2&gt;

&lt;p&gt;Here are some of the decisions behind SPIP-serverless:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Compute runs on the Function-as-a-Service &lt;strong&gt;AWS Lambda&lt;/strong&gt;. Running PHP in there is possible thanks to &lt;a href="https://bref.sh/" rel="noopener noreferrer"&gt;Bref&lt;/a&gt;, which gives PHP a real Lambda runtime (FPM). (For the full picture of how PHP fits into AWS serverless, I wrote a series that starts here: &lt;a href="https://dev.to/aws-builders/how-to-run-php-on-aws-serverless-architecture-part-1-whats-serverless-3j3m"&gt;How to run PHP on AWS serverless&lt;/a&gt;.)&lt;/li&gt;
&lt;li&gt;Sessions do not live on the local disk: they go to DynamoDB (on demand).&lt;/li&gt;
&lt;li&gt;Uploaded media does not live on the local disk: it goes to S3 (direct presigned uploads from the browser, a stream wrapper, and a small patch so SPIP reads and writes its media on S3).&lt;/li&gt;
&lt;li&gt;A CloudFront CDN caches content so it can be served without necessarily running SPIP's code on every request.&lt;/li&gt;
&lt;li&gt;Secret keys are generated once and stored in SSM Parameter Store, then reinjected at boot so every environment shares the same keys.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  But what about the database? A relational database costs an arm and a leg!
&lt;/h3&gt;

&lt;p&gt;The brick that makes pay-as-you-go real is Aurora DSQL, a serverless Postgres billed for what you use.&lt;/p&gt;

&lt;p&gt;A SQL CMS normally forces an always-on database, hence a fixed cost floor; DSQL removes that floor, and connections use short-lived IAM tokens (no stored password).&lt;/p&gt;

&lt;p&gt;SPIP usually relies on MySQL, though behind an abstraction layer (I would not call it an ORM ^^), so I had to tinker a bit to get Aurora DSQL working. Fortunately, the community had already done an experimental port to PostgreSQL.&lt;/p&gt;

&lt;p&gt;With Lambda and API Gateway per request, DynamoDB on demand, S3 and CloudFront for delivery, an idle site costs almost nothing and wakes up on the first request.&lt;/p&gt;

&lt;h3&gt;
  
  
  The serverless tradeoffs
&lt;/h3&gt;

&lt;p&gt;The essential tradeoff is in SPIP administration: serverless rests on an immutable-infrastructure paradigm (the container that runs SPIP's code cannot be modified at runtime, and even if it could, it would be destroyed and replaced by another one a few minutes later).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For the editor, nothing changes: content is edited online (database and S3).&lt;/li&gt;
&lt;li&gt;But the administrator's tasks (editing templates, deploying plugins, version upgrades) happen through code and continuous integration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Versioned, reviewed, reproducible: a real modern dev workflow :)&lt;/p&gt;

&lt;h2&gt;
  
  
  And on the seventh day, Paul @ TerraCloud rested
&lt;/h2&gt;

&lt;p&gt;The code is open source (MIT) and generic: you can deploy your own SPIP the same way.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;To get started, visit the &lt;a href="https://github.com/psantus/spip-serverless" rel="noopener noreferrer"&gt;github.com/psantus/spip-serverless&lt;/a&gt; repository&lt;/li&gt;
&lt;li&gt;and browse &lt;a href="https://d3l8qij0w6ijg3.cloudfront.net/" rel="noopener noreferrer"&gt;my demo site&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As for me, I will not rest completely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;First, I will be counting your stars on the repo.&lt;/li&gt;
&lt;li&gt;Then, I plan to maintain and improve this project based on real needs:

&lt;ul&gt;
&lt;li&gt;If something is missing for your use case, open an issue and let's talk.&lt;/li&gt;
&lt;li&gt;If you would like a hand building or deploying your own SPIP-based project, reach out on &lt;a href="https://www.terracloud.fr" rel="noopener noreferrer"&gt;terracloud.fr&lt;/a&gt; or via &lt;a href="https://www.linkedin.com/in/paulsantus/" rel="noopener noreferrer"&gt;LinkedIn!&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>spip</category>
      <category>cms</category>
      <category>serverless</category>
      <category>aws</category>
    </item>
    <item>
      <title>L'écureuil volant : quand le CMS Spip devient serverless</title>
      <dc:creator>Paul SANTUS</dc:creator>
      <pubDate>Tue, 08 Sep 2026 21:37:02 +0000</pubDate>
      <link>https://dev.to/aws-builders/lecureuil-volant-quand-le-cms-spip-devient-serverless-409</link>
      <guid>https://dev.to/aws-builders/lecureuil-volant-quand-le-cms-spip-devient-serverless-409</guid>
      <description>&lt;h2&gt;
  
  
  Au commencement..
&lt;/h2&gt;

&lt;p&gt;Le Web français était informe et vide.. quelques sites (universitaires notamment), reliés aux autres par des liens et référencées par des annuaires. &lt;/p&gt;

&lt;p&gt;1996 : les premiers services d'hébergement web, Mygale, puis Chez.com. « &lt;em&gt;Il y eut un soir, il y eu un matin. Premier jour&lt;/em&gt; »&lt;/p&gt;

&lt;p&gt;1999 : Adolescent, j'apprends alors PHP. « &lt;em&gt;Il y eut un soir, il y eu un matin. Deuxième jour&lt;/em&gt; »&lt;/p&gt;

&lt;p&gt;2000 : Les premiers CMS donnent aux non-développeurs la possibilité de devenir éditeurs. Drupal, puis Spip. Et les bases de données MySQL. Pas trop de contenu à publier de mon côté, mais je lis beaucoup. Je vois des sites (beaucoup militants de gauche comme uZine, rezo.net..) se saisir de l'outil. « &lt;em&gt;Il y eut un soir, il y eu un matin. Troisième jour&lt;/em&gt; »&lt;/p&gt;

&lt;p&gt;2003 : Wordpress et Dotclear. Tellement facile d'écrire sans développer ; je laisse de côté un temps le dev. « &lt;em&gt;Il y eut un soir, il y eu un matin. Quatrième jour&lt;/em&gt; »&lt;/p&gt;

&lt;p&gt;2016 : grâce à des précurseurs, je découvre le cloud. Je suis bluffé par la capacité d'AWS de mobiliser de l'infra à la demande, et la possibilité pour moi de proposer des applications hautement disponibles et scalables. « &lt;em&gt;Il y eut un soir, il y eu un matin...&lt;/em&gt; »&lt;/p&gt;

&lt;h2&gt;
  
  
  Et soudain.
&lt;/h2&gt;

&lt;p&gt;Mai 2026, un coup de fil. « &lt;em&gt;Paul, je veux développer une app. Mais j'ai une exigence non-négociable : le backend doit s'appuyer sur SPIP&lt;/em&gt;. »&lt;/p&gt;

&lt;p&gt;Comme souvent sur les projets que j'accompagne : pas de compétence infra disponible. Et l'ambition de proposer une application grand public (bientôt plus d'info là-dessus). &lt;/p&gt;

&lt;p&gt;Le CMS étant imposé, comment &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;le faire tourner sans payer 24h/24 (y compris à 3h du matin quand il n'y a pas un seul visiteur ?)&lt;/li&gt;
&lt;li&gt;sans maintenir de serveurs &lt;/li&gt;
&lt;li&gt;en suivant la charge quand notre appli va cartonner ? &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Le serverless, bien sûr. Reste à savoir si ça allait marcher.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le 6e jour, Paul créa SPIP-serverless
&lt;/h2&gt;

&lt;p&gt;Je découvre SPIP, CMS libre français, écrit en PHP et maintenu par sa communauté depuis 26 ans. L'architecture du projet n'est pas moderne, moderne.. et surtout, il est conçu pour être simple d'administration, pour des non-tech, pour être déployé en mode mono-machine et non dans un environnement de calculé distribué pour de la haute dispo : pas de gestion de session dans un cache partagé, upgrade "in-place", etc. &lt;/p&gt;

&lt;p&gt;Avec son besoin d'un disque local persistant et d'une base de données toujours allumée, ça n'en faisait pas le candidat naturel pour le serverless.&lt;/p&gt;

&lt;p&gt;Et pourtant, je suis fier de vous annoncer aujourd'hui &lt;strong&gt;spip-serverless&lt;/strong&gt;, un port de Spip sur une architecture 100% serverless. Vous pouvez ranger votre hébergement mutu :)&lt;/p&gt;

&lt;p&gt;Le projet est &lt;a href="https://github.com/psantus/spip-serverless" rel="noopener noreferrer"&gt;disponible sur Github&lt;/a&gt;, en licence MIT. Et vous pouvez le voir en action &lt;a href="https://d3l8qij0w6ijg3.cloudfront.net/" rel="noopener noreferrer"&gt;sur ce site de démo&lt;/a&gt;. Il se passera sans doute de longs mois avant que ce site me coûte un seul euro en hébergement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spip-serverless, comment ça fonctionne ? Les principaux choix d'architecture
&lt;/h2&gt;

&lt;p&gt;Voici quelques-uns des partis pris pour faire Spip-serverless : &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Le calcul est hébergé sur le service de Function-as-a-Service &lt;strong&gt;AWS Lambda&lt;/strong&gt;. Faire tourner PHP là-dedans est possible grâce à &lt;a href="https://bref.sh/" rel="noopener noreferrer"&gt;Bref&lt;/a&gt;, qui donne à PHP un vrai runtime Lambda (FPM). (Pour comprendre en détail comment PHP s'intègre au serverless AWS, j'ai écrit une série qui commence ici : &lt;a href="https://dev.to/aws-builders/how-to-run-php-on-aws-serverless-architecture-part-1-whats-serverless-3j3m"&gt;How to run PHP on AWS serverless&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Les sessions ne vivent pas sur le disque local : elles vont dans DynamoDB (à la demande).&lt;/li&gt;
&lt;li&gt;Les médias uploadés ne vivent pas sur le disque local : ils vont sur S3 (upload présigné direct depuis le navigateur, stream wrapper, et un petit patch pour que SPIP lise et écrive ses médias sur S3).&lt;/li&gt;
&lt;li&gt;Un CDN CloudFront permet de mettre en cache les contenus pour les servir sans nécessairement que le code de Spip tourne pour chaque requête. &lt;/li&gt;
&lt;li&gt;Les clés secrètes sont générées une fois et rangées dans SSM Parameter Store, puis réinjectées au démarrage pour que chaque environnement partage les mêmes clés.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Mais la base de données ? Une base relationnelle, ça coûte un bras !
&lt;/h3&gt;

&lt;p&gt;La brique qui rend le pay-as-you-go réel, c'est Aurora DSQL, un Postgres serverless facturé à l'usage. &lt;/p&gt;

&lt;p&gt;Un CMS SQL impose normalement une base toujours allumée, donc un plancher de coût fixe ; DSQL fait disparaître ce plancher, et les connexions utilisent des jetons IAM éphémères (aucun mot de passe stocké). &lt;/p&gt;

&lt;p&gt;Spip s'appuyant habituellement sur MySQL, mais avec une couche d'abstraction (j'appelle pas ça un ORM ^^), j'ai dû un peu bricoler pour qu'Aurora DSQL fonctionne. Heureusement, la communauté avait déjà fait un portage expérimental vers Postgresql. &lt;/p&gt;

&lt;p&gt;Avec Lambda et API Gateway à la requête, DynamoDB à la demande, S3 et CloudFront pour la diffusion, un site au repos ne coûte quasiment rien et se réveille à la première requête.&lt;/p&gt;

&lt;h3&gt;
  
  
  Les compromis du serverless
&lt;/h3&gt;

&lt;p&gt;Le compromis essentiel est dans l'administration de spip : en effet, le serverless repose sur un paradigme d'infra immuable (le conteneur qui exécute le code spip ne peut être modifié au runtime, et, quand bien même, sera détruit et remplacé par un autre quelques minutes après).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pour l'éditeur, rien ne change : le contenu s'édite en ligne (base et S3)&lt;/li&gt;
&lt;li&gt;mais les tâches de l'administrateur (édition de squelettes, déploiement de plugins, montées de version) se fait par le code et l'intégration continue. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Versionné, relu, reproductible : un vrai workflow de dev moderne :)&lt;/p&gt;

&lt;h2&gt;
  
  
  Et le 7e jour, Paul @ TerraCloud se reposa
&lt;/h2&gt;

&lt;p&gt;Le code est open source (MIT) et générique : tu peux déployer ton propre SPIP de la même façon. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pour commencer, visitez le &lt;a href="https://github.com/psantus/spip-serverless" rel="noopener noreferrer"&gt;dépôt github.com/psantus/spip-serverless&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;et parcourez &lt;a href="https://d3l8qij0w6ijg3.cloudfront.net/" rel="noopener noreferrer"&gt;mon site de démo&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Quant à moi, je ne vais pas complètement me reposer : &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Déjà je vais compter vos étoiles sur le repo&lt;/li&gt;
&lt;li&gt;Ensuite, je compte maintenir et améliorer ce projet en fonction des besoins réels : 

&lt;ul&gt;
&lt;li&gt;S'il manque quelque chose pour votre cas d'usage, ouvrez une issue et discutons-en.&lt;/li&gt;
&lt;li&gt;Si vous souhaitez un coup de main pour développer, déployer votre propre projet à base de spip, contactez moi sur &lt;a href="https://www.terracloud.fr" rel="noopener noreferrer"&gt;terracloud.fr&lt;/a&gt; ou via &lt;a href="https://www.linkedin.com/in/paulsantus/" rel="noopener noreferrer"&gt;LinkedIn!&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>spip</category>
      <category>cms</category>
      <category>serverless</category>
      <category>aws</category>
    </item>
    <item>
      <title>Fed up with repeated `aws sso login`? Fix your config!</title>
      <dc:creator>Paul SANTUS</dc:creator>
      <pubDate>Tue, 08 Sep 2026 08:09:38 +0000</pubDate>
      <link>https://dev.to/aws-builders/fed-up-with-repeated-aws-sso-login-fix-your-config-1kp2</link>
      <guid>https://dev.to/aws-builders/fed-up-with-repeated-aws-sso-login-fix-your-config-1kp2</guid>
      <description>&lt;p&gt;If you juggle a lot of AWS accounts through IAM Identity Center, you know the ritual. You sit down, run a command, and get slapped with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;aws: [ERROR]: Error when retrieving token from sso: Token has expired and refresh failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So you type &lt;code&gt;aws sso login&lt;/code&gt; again. And again tomorrow. And again after lunch.&lt;/p&gt;

&lt;p&gt;Here's the thing I figured out the hard way: if, just like me, your config file was initiated years ago, then maybe your config is still using the old format. Back then the AWS CLI wrote SSO settings inline into every single profile, and that style never really opted into refresh tokens the way the modern one does. The file kept working, so I never touched it, and I paid for that with a login prompt several times a day.&lt;/p&gt;

&lt;p&gt;If you get hit by frequent login requests, most of the time this isn't AWS being annoying. It's either a config that never asks for refresh tokens, or your org making it painful for you. Let's fix both.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two tokens you need to understand
&lt;/h2&gt;

&lt;p&gt;There isn't a single "session" behind &lt;code&gt;aws sso&lt;/code&gt;. There are two tokens, they have very different lifetimes, and almost every "why did I get logged out?" question comes from mixing them up.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Token&lt;/th&gt;
&lt;th&gt;Typical validity&lt;/th&gt;
&lt;th&gt;Configurable?&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Access token&lt;/td&gt;
&lt;td&gt;~8 hours&lt;/td&gt;
&lt;td&gt;No (fixed by AWS OIDC)&lt;/td&gt;
&lt;td&gt;The short-lived token the CLI presents to get role credentials&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refresh token&lt;/td&gt;
&lt;td&gt;Up to your sign-in session duration (max 90 days)&lt;/td&gt;
&lt;td&gt;Yes, in Identity Center settings&lt;/td&gt;
&lt;td&gt;Silently mints a new access token when the old one expires&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The access token expiring is completely normal. It happens roughly every 8 hours and you're not supposed to notice, because the CLI is meant to take the refresh token and quietly exchange it for a new access token behind your back.&lt;/p&gt;

&lt;p&gt;If you keep having to log in by hand, it usually comes down to one of three things. &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Either your cached session has no refresh token at all, which is a config and scope problem you can fix. &lt;/li&gt;
&lt;li&gt;Or your sign-in session duration is short, so the refresh token itself dies overnight. &lt;/li&gt;
&lt;li&gt;Or a single refresh just failed for a transient reason, like being offline, behind a VPN, or with the laptop asleep at the wrong moment.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Fix number one: use a shared &lt;code&gt;sso-session&lt;/code&gt; block
&lt;/h2&gt;

&lt;p&gt;This is the change that matters most. Adopt the modern &lt;code&gt;[sso-session]&lt;/code&gt; construct in &lt;code&gt;~/.aws/config&lt;/code&gt;. It's what makes your logins refresh-token capable, and as a bonus it lets one &lt;code&gt;aws sso login&lt;/code&gt; cover every profile at once.&lt;/p&gt;

&lt;p&gt;Here is the old, painful style, with the SSO settings copied into each profile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[profile my-account]&lt;/span&gt;
&lt;span class="py"&gt;sso_start_url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;https://d-xxxxxxxxxx.awsapps.com/start/&lt;/span&gt;
&lt;span class="py"&gt;sso_region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;eu-west-1&lt;/span&gt;
&lt;span class="py"&gt;sso_account_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;123456789012&lt;/span&gt;
&lt;span class="py"&gt;sso_role_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;AdministratorAccess&lt;/span&gt;
&lt;span class="py"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;eu-west-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repeating &lt;code&gt;sso_start_url&lt;/code&gt; and &lt;code&gt;sso_region&lt;/code&gt; in every profile is the legacy token-provider style. It's verbose, it drifts over time, and it doesn't share a refreshable session across profiles.&lt;/p&gt;

&lt;p&gt;Here is the modern style, where you declare the session once and point profiles at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# Declare the SSO session ONCE. This is the refresh-capable part.
&lt;/span&gt;&lt;span class="nn"&gt;[sso-session sso]&lt;/span&gt;
&lt;span class="py"&gt;sso_start_url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;https://d-xxxxxxxxxx.awsapps.com/start/&lt;/span&gt;
&lt;span class="py"&gt;sso_region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;eu-west-1&lt;/span&gt;
&lt;span class="py"&gt;sso_registration_scopes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;sso:account:access&lt;/span&gt;

&lt;span class="nn"&gt;[default]&lt;/span&gt;
&lt;span class="py"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;

&lt;span class="nn"&gt;[profile account-one]&lt;/span&gt;
&lt;span class="py"&gt;sso_session&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;sso&lt;/span&gt;
&lt;span class="py"&gt;sso_account_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;111111111111&lt;/span&gt;
&lt;span class="py"&gt;sso_role_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;AdministratorAccess&lt;/span&gt;
&lt;span class="py"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;eu-west-1&lt;/span&gt;

&lt;span class="nn"&gt;[profile account-two]&lt;/span&gt;
&lt;span class="py"&gt;sso_session&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;sso&lt;/span&gt;
&lt;span class="py"&gt;sso_account_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;222222222222&lt;/span&gt;
&lt;span class="py"&gt;sso_role_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;AdministratorAccess&lt;/span&gt;
&lt;span class="py"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;eu-west-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few reasons this is simply better. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One login now covers all your profiles, because &lt;code&gt;aws sso login --profile account-one&lt;/code&gt; refreshes the token for everything that references &lt;code&gt;sso_session = sso&lt;/code&gt;. The &lt;code&gt;sso_registration_scopes = sso:account:access&lt;/code&gt; line is the piece that makes the client registration ask for a refresh grant in the first place; without it you can end up holding only an 8-hour access token with nothing to refresh from, which is exactly the "refresh failed" trap. &lt;/li&gt;
&lt;li&gt;You also get a single source of truth, so changing the start URL or region is a one-line edit instead of a find-and-replace across dozens of profiles. The only requirement is AWS CLI v2, which you almost certainly already run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Assume-role and chained profiles sit happily on top of this. Just point their &lt;code&gt;source_profile&lt;/code&gt; at an SSO profile that uses the shared session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[profile prod-admin]&lt;/span&gt;
&lt;span class="py"&gt;role_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;arn:aws:iam::111122223333:role/MyAdminRole&lt;/span&gt;
&lt;span class="py"&gt;role_session_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;me&lt;/span&gt;
&lt;span class="py"&gt;source_profile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;account-one&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Fix number two: raise the sign-in session duration
&lt;/h2&gt;

&lt;p&gt;Even with a perfect config, if your sign-in session duration is stuck at the 8-hour default, the refresh token can't survive the night. That setting is the real ceiling on how long a refresh token stays valid.&lt;/p&gt;

&lt;p&gt;It's a console-only, org-wide parameter. There is no public API, CLI, CloudFormation, or Terraform surface for it, so this is one you have to click through yourself. Open the console, go to IAM Identity Center in your instance's home account and region, then Settings, then the Authentication tab, then Session settings, and edit the maximum session duration. You'll see separate values for user interactive sessions, user background sessions, and CLI or tool sessions. The maximum is 90 days.&lt;/p&gt;

&lt;p&gt;One warning worth taking seriously: this is an org-wide policy. It affects every user in the Identity Center instance, not just you. Raising it trades a little security posture for a lot fewer logins, so pick a number your security team is comfortable with. Seven days is a common sweet spot, and 90 days is the ceiling. If you want your organisation to raise its duration, be prepared to make a strong case for Security people (mentioning that &lt;a href="https://docs.aws.amazon.com/singlesignon/latest/userguide/authconcept.html" rel="noopener noreferrer"&gt;IDC tokens can be revoked&lt;/a&gt; is a good start for a security conversation).&lt;/p&gt;

&lt;p&gt;There's a second, different duration that people confuse with this one: the permission set session, which controls how long the assumed-role credentials last (often 12 hours). That one you can read from the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;INSTANCE_ARN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws sso-admin list-instances &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Instances[0].InstanceArn'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text &lt;span class="nt"&gt;--region&lt;/span&gt; eu-west-1&lt;span class="si"&gt;)&lt;/span&gt;

aws sso-admin describe-permission-set &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--instance-arn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INSTANCE_ARN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--permission-set-arn&lt;/span&gt; &amp;lt;ps-arn&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'PermissionSet.{Name:Name,SessionDuration:SessionDuration}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; eu-west-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Just remember it's a different clock. It governs role-credential lifetime, not SSO refresh.&lt;/p&gt;

&lt;h2&gt;
  
  
  How refresh timing actually works
&lt;/h2&gt;

&lt;p&gt;A common assumption is that you have to refresh "every 8 hours." You don't, and there's no background daemon doing it for you either. Refresh is lazy and on demand. The CLI only refreshes when you run a command that needs credentials. If the access token is still valid it just uses it. If it's expired, it silently swaps the refresh token for a new access token and carries on. And each successful refresh usually rotates the refresh token, sliding the window forward.&lt;/p&gt;

&lt;p&gt;In practice that means you should use any profile well within your session ceiling, and daily use is more than enough. During a normal working week you'll basically never type &lt;code&gt;aws sso login&lt;/code&gt;. The first command each morning triggers a silent refresh, and everything after it rides the fresh 8-hour access token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the token cache tidy
&lt;/h2&gt;

&lt;p&gt;The CLI caches tokens under &lt;code&gt;~/.aws/sso/cache/&lt;/code&gt;. Over the months this fills up with stale files from old client registrations. They rarely cause trouble, but a cluttered cache can occasionally make the CLI match an older registration than you expect. It's safe to remove the expired token files as long as you leave the active one for your current session in place. This snippet shows what's there and when each entry expires, without printing any secrets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; ~/.aws/sso/cache/&lt;span class="k"&gt;*&lt;/span&gt;.json&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json,sys;
try: d=json.load(open('&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;'))
except: sys.exit()
print('&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;', '-&amp;gt;', d.get('expiresAt'), '| refresh:', 'refreshToken' in d)"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  To sum up
&lt;/h2&gt;

&lt;p&gt;The access token lasts about 8 hours and you can't change that. The refresh token lasts up to 90 days depending on your Identity Center setting, and that's the one that actually keeps you logged in. Move to a shared &lt;code&gt;[sso-session]&lt;/code&gt; block with &lt;code&gt;sso_registration_scopes = sso:account:access&lt;/code&gt; so refresh tokens get issued and one login covers everything. &lt;/p&gt;

&lt;p&gt;Raise the maximum session duration in IAM Identity Center under Settings and Authentication, remembering it's console-only and org-wide. Don't bother scheduling refreshes, because the CLI renews lazily on your next command and daily use is plenty.&lt;/p&gt;

&lt;p&gt;Fix the config once, set a sane session duration, and &lt;code&gt;aws sso login&lt;/code&gt; turns into something you type a handful of times a year instead of a handful of times a day.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cli</category>
      <category>sso</category>
      <category>developer</category>
    </item>
    <item>
      <title>AWS WAF Challenge: Blocking Bots Before They Reach the Application</title>
      <dc:creator>Paul SANTUS</dc:creator>
      <pubDate>Fri, 14 Aug 2026 09:17:29 +0000</pubDate>
      <link>https://dev.to/aws-builders/aws-waf-challenge-blocking-bots-before-they-reach-the-application-2ghj</link>
      <guid>https://dev.to/aws-builders/aws-waf-challenge-blocking-bots-before-they-reach-the-application-2ghj</guid>
      <description>&lt;p&gt;When I was called in, the attack had been going on for about a week.&lt;/p&gt;

&lt;p&gt;It was targeting the login page of a legacy application that generated its HTML server-side.&lt;/p&gt;

&lt;p&gt;Requests numbered in the millions and came from a very large number of IP addresses, which made IP-based blocking largely ineffective. Unlike other campaigns I had encountered, the attacker was also rotating JA3 and JA4 fingerprints. Even rate limiting aggregated on those signals could therefore provide only a partial response.&lt;/p&gt;

&lt;p&gt;A few days later, a similar attack targeted a second application belonging to my client. This time, it was no longer the legacy server-side application, but a SPA (single-page application) calling a JSON API to create accounts.&lt;/p&gt;

&lt;p&gt;These two incidents gave me the opportunity to use both integration modes offered by AWS WAF Challenge:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the challenge returned directly by WAF for an HTML page;&lt;/li&gt;
&lt;li&gt;the challenge solved beforehand by &lt;code&gt;challenge.js&lt;/code&gt;, then passed to an API called with &lt;code&gt;fetch&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why Put the Challenge at the Infrastructure Layer?
&lt;/h2&gt;

&lt;p&gt;If my client called me after seven days, it was because the team had first tried to deal with the attack at the application layer by integrating Cloudflare Turnstile.&lt;/p&gt;

&lt;p&gt;The integration was solid. A PrestaShop module managed the keys, global activation, and separate configurations for each tenant. On submission, the application retrieved the token from the form and validated it directly with Cloudflare:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$turnstileToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Tools&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'cf-turnstile-response'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$turnstileToken&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$turnstileValid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;elseif&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;verifyTurnstileToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$turnstileToken&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$turnstileValid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Validation then required a server-side call to &lt;code&gt;siteverify&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Tools&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;file_get_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'https://challenges.cloudflare.com/turnstile/v0/siteverify'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nb"&gt;stream_context_create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'http'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'method'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'POST'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'header'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/x-www-form-urlencoded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;http_build_query&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
                &lt;span class="s1"&gt;'secret'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$secretKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'response'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'remoteip'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Tools&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getRemoteAddr&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="p"&gt;]),&lt;/span&gt;
            &lt;span class="s1"&gt;'timeout'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This approach worked, but only partially: a significant proportion of the traffic still managed to obtain valid tokens. In particular, we observed tokens generated from a group of Dutch IP addresses and then presented to our infrastructure from Spain. The attacker had therefore industrialized token acquisition and circulation. A token proved that a challenge had been solved, but it was neither a persistent identity nor, in this flow, strictly bound to the IP address that obtained it.&lt;/p&gt;

&lt;p&gt;More importantly, this protection had a major drawback: before rejecting a request, the entire chain had to be engaged: the CDN, load balancer, web server, PHP, framework, and our validation code. Even when authentication failed, a significant part of the technical cost had already been incurred. With millions of calls, this affected application performance, although autoscaling limited the impact, as well as the bill.&lt;/p&gt;

&lt;p&gt;With a Challenge rule placed in AWS WAF, which the application was already using elsewhere, a request without a valid token is stopped at the edge. It consumes no PHP worker, no database connection, and no application compute capacity. That was exactly what we needed when facing several million requests.&lt;/p&gt;

&lt;p&gt;Moving the control also brings a maintenance benefit. Any validation managed by the application necessarily involves code in the form, the controller, secret management, the remote call, and error messages. With a challenge managed by WAF, the backend is not even aware of its existence. It receives only requests that have already passed the check.&lt;/p&gt;

&lt;p&gt;The provider of the application-level challenge, Cloudflare versus AWS, is not the point of this comparison. The same distinction would exist with any mechanism validated in the backend. What I want to highlight here, even though I admit I was tired of seeing the developer give in to the “obvious Cloudflare choice” because of its dominant position instead of calling me on the first day of the attack ^^, is the architectural difference between a control managed by the application and one placed at the edge, upstream from the origin.&lt;/p&gt;

&lt;h2&gt;
  
  
  First Implementation: An Application That Generates Its HTML Server-Side
&lt;/h2&gt;

&lt;p&gt;On the legacy application, authentication uses a native HTML form submitted with &lt;code&gt;POST&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This detail matters. When a user opens an HTML page, the HTTP response becomes a document that the browser must display. AWS WAF can therefore intercept this navigation and respond with its Challenge request. The browser executes the JavaScript provided by AWS WAF, performs the silent proof of work, obtains a token, and the interstitial script then transparently retries the backend request, which WAF now allows through.&lt;/p&gt;

&lt;p&gt;In this case, implementing the challenge with AWS WAF is extremely simple.&lt;/p&gt;

&lt;p&gt;I started by assigning a label to requests matching the login flow. The challenge rule takes just 15 lines of declarative code, including observability!&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ChallengeLoginNoToken"&lt;/span&gt;
  &lt;span class="nx"&gt;priority&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt;

  &lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;challenge&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;statement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;label_match_statement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;key&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"login"&lt;/span&gt;
      &lt;span class="nx"&gt;scope&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"LABEL"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;visibility_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;cloudwatch_metrics_enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="nx"&gt;metric_name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ChallengeLoginNoToken"&lt;/span&gt;
    &lt;span class="nx"&gt;sampled_requests_enabled&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;login&lt;/code&gt; label was assigned by an earlier rule to POST requests targeting &lt;code&gt;/login&lt;/code&gt;. This separation between detection and action makes it possible to reuse the same scope for other protections, such as rate limiting by IP address or JA4 fingerprint.&lt;/p&gt;

&lt;p&gt;For a server-side site, there is nothing to add to the PHP code. There is no SDK to integrate and no token to validate in the application controller.&lt;/p&gt;

&lt;p&gt;This is the simplest configuration: the browser requests a document, WAF can respond with an executable document, and the application is called only after the challenge has been solved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation 2: AWS WAF Challenge on a SPA
&lt;/h2&gt;

&lt;p&gt;The second application used a SPA, built with React, for its interface and an API hosted on another subdomain.&lt;/p&gt;

&lt;p&gt;Account creation conceptually looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.example.com/account-registration&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If AWS WAF returns its challenge directly to this request, the response arrives inside &lt;code&gt;fetch&lt;/code&gt;. The browser does not interpret it as a new document and does not automatically execute the JavaScript contained in the response. The SPA therefore receives a &lt;code&gt;202&lt;/code&gt; status, potentially with a body it does not know how to process, and the registration flow fails.&lt;/p&gt;

&lt;p&gt;This is a property of the browser execution model, not a limitation specific to AWS. JavaScript returned as data by an XHR or &lt;code&gt;fetch&lt;/code&gt; request is not executed spontaneously.&lt;/p&gt;

&lt;p&gt;The solution is to reverse the order of operations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the SPA loads the AWS WAF &lt;code&gt;challenge.js&lt;/code&gt; SDK;&lt;/li&gt;
&lt;li&gt;the SDK silently solves the challenge within the page;&lt;/li&gt;
&lt;li&gt;the SPA retrieves a token;&lt;/li&gt;
&lt;li&gt;it attaches that token to the API request;&lt;/li&gt;
&lt;li&gt;WAF verifies the token before allowing the request through.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Configuring WAF for a SPA
&lt;/h3&gt;

&lt;p&gt;The Web ACL must first know the domains on which its tokens are valid. In my case, the frontend and API used two subdomains of the same root domain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_wafv2_web_acl"&lt;/span&gt; &lt;span class="s2"&gt;"acl_cloudfront"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;us-east-1&lt;/span&gt;

  &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cloudfront-api-acl"&lt;/span&gt;
  &lt;span class="nx"&gt;scope&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"CLOUDFRONT"&lt;/span&gt;

  &lt;span class="nx"&gt;token_domains&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"example.com"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="nx"&gt;default_action&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I then assign a label to POST requests targeting the sensitive endpoint and verify the token with a Challenge action:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ChallengeRegister"&lt;/span&gt;
  &lt;span class="nx"&gt;priority&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;

  &lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;challenge&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;statement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;label_match_statement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;key&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"register"&lt;/span&gt;
      &lt;span class="nx"&gt;scope&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"LABEL"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;visibility_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;cloudwatch_metrics_enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="nx"&gt;metric_name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ChallengeRegister"&lt;/span&gt;
    &lt;span class="nx"&gt;sampled_requests_enabled&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Up to this point, the implementation is therefore the same as in V1. However, we need to send a valid token “on the first try,” since we cannot process the challenge synchronously.&lt;/p&gt;

&lt;p&gt;The JavaScript SDK URL and secret key to inject into the SPA are provided in the integration section of the AWS WAF console. I inject them into the SPA using an environment variable.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Small Implementation Detail That Can Become a Blocker
&lt;/h3&gt;

&lt;p&gt;The first version of my loader simply waited for the script's &lt;code&gt;load&lt;/code&gt; event. That was not enough. The initial &lt;code&gt;jsapi.js&lt;/code&gt; script loads &lt;code&gt;challenge.js&lt;/code&gt; in turn, then initializes &lt;code&gt;window.AwsWafIntegration&lt;/code&gt; asynchronously.&lt;/p&gt;

&lt;p&gt;The first script may therefore have finished loading while &lt;code&gt;getToken()&lt;/code&gt; is not yet available. The symptom was particularly misleading: the first submission was sent without a token, which was obtained just a few milliseconds later.&lt;/p&gt;

&lt;p&gt;I solved this problem with a bounded wait:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;waitForIntegration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeoutMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AwsWafIntegration&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;getToken&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="nf"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getWafToken&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;injectWafScript&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;waitForIntegration&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AwsWafIntegration&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The token is then placed in the header specified by AWS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RegisterPayload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wafToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getWafToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;publicClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/account-registration&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-aws-waf-token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;wafToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  A Second Detail That Can Become a Blocker
&lt;/h3&gt;

&lt;p&gt;In an architecture involving multiple subdomains, CORS must be taken into account, particularly the &lt;code&gt;OPTIONS&lt;/code&gt; preflight request, since adding the token makes the request “complex.”&lt;/p&gt;

&lt;p&gt;The header must be permitted by the response to the &lt;code&gt;OPTIONS&lt;/code&gt; request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Access-Control-Allow-Headers:
  Authorization,
  Content-Type,
  x-aws-waf-token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This therefore requires a small addition to the CloudFront configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Much Does This Protection Cost?
&lt;/h2&gt;

&lt;p&gt;Based on the public AWS pricing available in August 2026, the AWS WAF base service is billed at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;$5 per Web ACL per month;&lt;/li&gt;
&lt;li&gt;$1 per rule per month;&lt;/li&gt;
&lt;li&gt;$0.60 per million requests processed, within the standard WCU allocation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;Allow&lt;/code&gt;, &lt;code&gt;Block&lt;/code&gt;, and &lt;code&gt;Count&lt;/code&gt; actions do not add a per-action charge. Challenge responses are billed separately at $0.40 per million Challenge responses.&lt;/p&gt;

&lt;p&gt;This additional cost remains low, especially if the rule is limited to a few high-value entry points: login, registration, password recovery, order validation, or a sensitive business operation.&lt;/p&gt;

&lt;p&gt;The difference compared with the specialized Fraud Control rules is considerable. AWS WAF Account Takeover Prevention and Account Creation Fraud Prevention add a monthly subscription, followed by per-request charges with declining but still high rates. In AWS's public example, 15 million requests analyzed by ATP generate more than $8,000 in Fraud Control charges.&lt;/p&gt;

&lt;p&gt;ATP provides much more advanced capabilities. It simply does not address the same economic need. When a targeted Challenge action, rate limiting, and a few network signals are enough, the cost-effectiveness is hard to beat.&lt;/p&gt;

&lt;h2&gt;
  
  
  In Summary: One Solution, Two Implementations
&lt;/h2&gt;

&lt;p&gt;The first implementation takes 15 lines of Terraform because the browser navigates to an HTML document. AWS WAF can respond directly with the challenge JavaScript.&lt;/p&gt;

&lt;p&gt;The second requires a small frontend service because the protected call is made with &lt;code&gt;fetch&lt;/code&gt;. The challenge must be executed before the request, and its token must then be passed in a header or cookie.&lt;/p&gt;

&lt;p&gt;In both cases, the backend implements no challenge validation. It stores no additional secret key, calls no external service, and consumes no resources for rejected requests.&lt;/p&gt;

&lt;p&gt;AWS WAF Challenge is not a replacement for a complete anti-abuse strategy. It does, however, make it possible to move an expensive operation to the right place: before the application.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>waf</category>
      <category>security</category>
      <category>challenge</category>
    </item>
    <item>
      <title>AWS WAF Challenge : bloquer les bots avant qu’ils n’atteignent l’application</title>
      <dc:creator>Paul SANTUS</dc:creator>
      <pubDate>Fri, 14 Aug 2026 09:10:35 +0000</pubDate>
      <link>https://dev.to/aws-builders/aws-waf-challenge-bloquer-les-bots-avant-quils-natteignent-lapplication-1np9</link>
      <guid>https://dev.to/aws-builders/aws-waf-challenge-bloquer-les-bots-avant-quils-natteignent-lapplication-1np9</guid>
      <description>&lt;p&gt;Quand on m’a appelé, l’attaque durait depuis environ une semaine.&lt;/p&gt;

&lt;p&gt;Elle visait la page de connexion d’une application historique qui générait son HTML côté serveur. &lt;/p&gt;

&lt;p&gt;Les requêtes se comptaient en millions et provenaient d’un très grand nombre d’adresses IP, ce qui rendait un blocage par IP peu efficace. Contrairement à d’autres campagnes que j’avais rencontrées, l’assaillant faisait également tourner ses empreintes JA3 et JA4. Même un rate limiting agrégé sur ces signaux ne pouvait donc apporter qu’une réponse partielle.&lt;/p&gt;

&lt;p&gt;Quelques jours plus tard, une attaque similaire a visé une deuxième application chez mon client. Cette fois, il ne s’agissait plus de l’ancienne application server-side, mais d’une SPA (single page app) qui appelait une API JSON pour créer les comptes.&lt;/p&gt;

&lt;p&gt;Ces deux incidents m’ont permis d’utiliser les deux modes d’intégration de la fonctionnalité Challenge d’AWS WAF :&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;le challenge directement renvoyé par le WAF pour une page HTML ;&lt;/li&gt;
&lt;li&gt;le challenge résolu en amont par &lt;code&gt;challenge.js&lt;/code&gt;, puis transmis à une API appelée avec &lt;code&gt;fetch&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Pourquoi placer le challenge côté infrastructure ?
&lt;/h2&gt;

&lt;p&gt;Si mon client m'a appelé au bout de 7 jours, c'est que l'équipe a d'abord essayé de traiter l'attaque au niveau applicatif, via l'intégration de Cloudflare Turnstile. &lt;/p&gt;

&lt;p&gt;L’intégration était sérieuse. Un module PrestaShop gérait les clés, l’activation globale et des configurations distinctes selon le tenant. Lors de la soumission, l’application récupérait le jeton du formulaire et le validait elle-même auprès de Cloudflare :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$turnstileToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Tools&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'cf-turnstile-response'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$turnstileToken&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$turnstileValid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;elseif&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;verifyTurnstileToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$turnstileToken&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$turnstileValid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La validation nécessitait ensuite un appel serveur vers &lt;code&gt;siteverify&lt;/code&gt; :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Tools&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;file_get_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'https://challenges.cloudflare.com/turnstile/v0/siteverify'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nb"&gt;stream_context_create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'http'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'method'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'POST'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'header'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/x-www-form-urlencoded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;http_build_query&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
                &lt;span class="s1"&gt;'secret'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$secretKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'response'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'remoteip'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Tools&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getRemoteAddr&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="p"&gt;]),&lt;/span&gt;
            &lt;span class="s1"&gt;'timeout'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cette approche fonctionnait, mais seulement partiellement  : une proportion significative du trafic obtenait malgré tout des jetons valides. Nous avons notamment observé des jetons générés depuis un groupe d’adresses IP néerlandaises, puis présentés à notre infrastructure depuis l'Espagne : l’attaquant avait donc industrialisé l’acquisition et la circulation des jetons. Le jeton prouvait qu’un challenge avait été résolu, mais il ne constituait ni une identité durable ni, dans ce flux, une liaison stricte avec l’adresse IP qui l’avait obtenu.&lt;/p&gt;

&lt;p&gt;Mais surtout, cette protection avait un défaut important : avant de rejeter une requête, il fallait mobiliser tout la chaine : le CDN, le load balancer, le serveur web, PHP, le framework, et notre code de validation : même quand l’authentification échouait, une partie significative du coût technique avait déjà été payée. Et avec des millions d'appels, ça avait un impact sur les performances de l'application (impact limité par l'autoscaling) et sur la facture.&lt;/p&gt;

&lt;p&gt;Avec une règle Challenge placée dans AWS WAF (qui était déjà utilisé par ailleurs par l'application), la requête sans jeton valide est arrêtée à la périphérie. Elle ne consomme ni worker PHP, ni connexion à la base de données, ni capacité de calcul applicative. C’était exactement ce dont nous avions besoin face à plusieurs millions de requêtes.&lt;/p&gt;

&lt;p&gt;Il y a aussi un bénéfice de maintenance propre au déplacement du contrôle. Toute validation gérée par l’application implique nécessairement du code dans le formulaire, le contrôleur, la gestion des secrets, l’appel distant et les messages d’erreur. Avec un challenge géré par le WAF, le backend ne connaît même pas son existence. Il ne reçoit que les requêtes qui ont déjà passé le contrôle.&lt;/p&gt;

&lt;p&gt;Le fournisseur du challenge applicatif (Cloudflare vs AWS) n’est pas le sujet de cette comparaison. La même distinction existerait avec n’importe quel mécanisme validé dans le backend. Ce que je veux relever ici (même si j'avoue avoir été lassé que le dev succombe à l'« évidence Cloudflare » liée à sa position dominante, plutôt que de m'appeler au premier de l'attaque ^^), c'est la différence architecturale entre un contrôle géré par l’application et un contrôle placé à la périphérie, en amont de l’origine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Première implémentation : une application qui génère son HTML côté serveur
&lt;/h2&gt;

&lt;p&gt;Sur l’application historique, l'authentification se fait par un formulaire HTML natif en &lt;code&gt;POST&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;Ce détail est important. Lorsqu’un utilisateur ouvre une page HTML, la réponse HTTP devient un document que le navigateur doit afficher. AWS WAF peut donc intercepter cette navigation et envoyer en réponse sa demande de Challenge. Le navigateur exécute le JavaScript fourni par AWS WAF, réalise le proof of work silencieux, obtient un jeton, puis reprend le script interstitiel relance de façon transparente l'appel au backend, appel que WAF laisse donc passer.&lt;/p&gt;

&lt;p&gt;Dans ce cas-là, l'implémentation du challenge par AWS WAF est ultra simple. &lt;/p&gt;

&lt;p&gt;J’ai commencé par poser un label sur les requêtes qui correspondaient au parcours de connexion. La règle de challenge  tient en 15 lignes de code déclaratif, observabilité comprise !&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ChallengeLoginNoToken"&lt;/span&gt;
  &lt;span class="nx"&gt;priority&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt;

  &lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;challenge&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;statement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;label_match_statement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;key&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"login"&lt;/span&gt;
      &lt;span class="nx"&gt;scope&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"LABEL"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;visibility_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;cloudwatch_metrics_enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="nx"&gt;metric_name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ChallengeLoginNoToken"&lt;/span&gt;
    &lt;span class="nx"&gt;sampled_requests_enabled&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Le label &lt;code&gt;login&lt;/code&gt; était attribué par une règle précédente aux requêtes POST portant sur &lt;code&gt;/login&lt;/code&gt; (cette séparation entre détection et action permet de réutiliser le même périmètre pour d’autres protections, par exemple une rate limit par IP ou par empreinte JA4).&lt;/p&gt;

&lt;p&gt;Dans le cas d’un site server-side, il n’y a rien à ajouter dans le code PHP. Il n’y a pas de SDK à intégrer, pas de jeton à valider dans le contrôleur applicatif.&lt;/p&gt;

&lt;p&gt;C’est la configuration la plus simple : le navigateur demande un document, le WAF peut répondre par un document exécutable, et l’application n’est appelée qu’après résolution du challenge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implémentation 2 : Challenge AWS WAF sur une SPA
&lt;/h2&gt;

&lt;p&gt;La deuxième application utilisait une SPA (appli React) pour son interface et une API sur un autre sous-domaine.&lt;/p&gt;

&lt;p&gt;La création de compte ressemble conceptuellement à ceci :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.example.com/account-registration&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Si AWS WAF renvoie directement son challenge à cette requête, la réponse arrive dans &lt;code&gt;fetch&lt;/code&gt;. Le navigateur ne l’interprète pas comme un nouveau document et n’exécute pas automatiquement le JavaScript contenu dans la réponse. La SPA reçoit donc un statut &lt;code&gt;202&lt;/code&gt;, éventuellement avec un corps qu’elle ne sait pas traiter, et le parcours d’inscription échoue.&lt;/p&gt;

&lt;p&gt;C’est une propriété du modèle d’exécution du navigateur, pas une limitation spécifique à AWS. Du JavaScript renvoyé comme donnée par une requête XHR ou &lt;code&gt;fetch&lt;/code&gt; n’est pas exécuté spontanément.&lt;/p&gt;

&lt;p&gt;La solution consiste à inverser l’ordre des opérations :&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;la SPA charge le SDK &lt;code&gt;challenge.js&lt;/code&gt; d’AWS WAF ;&lt;/li&gt;
&lt;li&gt;le SDK résout silencieusement le challenge dans la page ;&lt;/li&gt;
&lt;li&gt;la SPA récupère un jeton ;&lt;/li&gt;
&lt;li&gt;elle joint ce jeton à la requête API ;&lt;/li&gt;
&lt;li&gt;le WAF vérifie le jeton avant de laisser passer la requête.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Configurer le WAF pour une SPA
&lt;/h3&gt;

&lt;p&gt;La Web ACL doit d’abord connaître les domaines sur lesquels ses jetons sont valables. Dans mon cas, le frontend et l’API utilisaient deux sous-domaines du même domaine racine :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_wafv2_web_acl"&lt;/span&gt; &lt;span class="s2"&gt;"acl_cloudfront"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;us-east-1&lt;/span&gt;

  &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cloudfront-api-acl"&lt;/span&gt;
  &lt;span class="nx"&gt;scope&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"CLOUDFRONT"&lt;/span&gt;

  &lt;span class="nx"&gt;token_domains&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"example.com"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="nx"&gt;default_action&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Je pose ensuite un label sur les requêtes POST vers l’endpoint sensible, puis je vérifie le jeton avec une action Challenge :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ChallengeRegister"&lt;/span&gt;
  &lt;span class="nx"&gt;priority&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;

  &lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;challenge&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;statement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;label_match_statement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;key&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"register"&lt;/span&gt;
      &lt;span class="nx"&gt;scope&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"LABEL"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;visibility_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;cloudwatch_metrics_enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="nx"&gt;metric_name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ChallengeRegister"&lt;/span&gt;
    &lt;span class="nx"&gt;sampled_requests_enabled&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On retrouve donc jusqu'ici la même implémentation qu'en v1.. par contre, il nous faut envoyer un token valide "du premier coup" vu qu'on ne saurait pas traiter le challenge de façon synchrone. &lt;/p&gt;

&lt;p&gt;L’URL du SDK js et la secret key à injecter dans notre SPA sont fournies dans la section d’intégration de la console AWS WAF. Je l’injecte dans la SPA à partir d’une variable d’environnement.&lt;/p&gt;

&lt;h3&gt;
  
  
  Un petit détail d'implémentation qui peut être bloquant
&lt;/h3&gt;

&lt;p&gt;La première version de mon chargeur attendait simplement l’événement &lt;code&gt;load&lt;/code&gt; du script. Ce n’était pas suffisant. Le script initial &lt;code&gt;jsapi.js&lt;/code&gt; charge à son tour &lt;code&gt;challenge.js&lt;/code&gt;, puis initialise &lt;code&gt;window.AwsWafIntegration&lt;/code&gt; de manière asynchrone.&lt;/p&gt;

&lt;p&gt;Le chargement du premier script peut donc être terminé alors que &lt;code&gt;getToken()&lt;/code&gt; n’est pas encore disponible. Le symptôme était particulièrement trompeur : le premier submit partait sans jeton, celui-ci étant obtenu quelques milli-secondes plus tard.&lt;/p&gt;

&lt;p&gt;J’ai résolu ce problème avec une attente bornée :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;waitForIntegration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeoutMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AwsWafIntegration&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;getToken&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="nf"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getWafToken&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;injectWafScript&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;waitForIntegration&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AwsWafIntegration&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Le jeton est ensuite placé dans le header prévu par AWS :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RegisterPayload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wafToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getWafToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;publicClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/account-registration&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-aws-waf-token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;wafToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Deuxième détail qui peut bloquer
&lt;/h3&gt;

&lt;p&gt;Dans une architecture avec plusieurs sous-domaines, il faut penser au CORS (et notamment au preflight &lt;code&gt;OPTIONS&lt;/code&gt; puisque l'ajout du token rend la requête 'complexe'). &lt;/p&gt;

&lt;p&gt;Le header doit être autorisé par la réponse à la requête &lt;code&gt;OPTIONS&lt;/code&gt; :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Access-Control-Allow-Headers:
  Authorization,
  Content-Type,
  x-aws-waf-token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Un petit ajout à faire au niveau de la configuration Cloudfront, donc.&lt;/p&gt;

&lt;h2&gt;
  
  
  Combien coûte cette protection ?
&lt;/h2&gt;

&lt;p&gt;Aux tarifs publics AWS consultés en août 2026, le socle AWS WAF est facturé :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;5 dollars par Web ACL et par mois ;&lt;/li&gt;
&lt;li&gt;1 dollar par règle et par mois ;&lt;/li&gt;
&lt;li&gt;0,60 dollar par million de requêtes traitées, dans l’allocation WCU standard.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Les actions &lt;code&gt;Allow&lt;/code&gt;, &lt;code&gt;Block&lt;/code&gt; et &lt;code&gt;Count&lt;/code&gt; n’ajoutent pas de coût par action. Les réponses Challenge sont facturées séparément, à hauteur de 0,40 dollar par million de réponses Challenge.&lt;/p&gt;

&lt;p&gt;Ce coût additionnel reste faible, surtout si la règle est limitée à quelques points d’entrée à forte valeur : connexion, inscription, récupération de mot de passe, validation d’une commande ou opération métier sensible.&lt;/p&gt;

&lt;p&gt;La différence avec les règles spécialisées Fraud Control est considérable. AWS WAF Account Takeover Prevention et Account Creation Fraud Prevention ajoutent un abonnement mensuel, puis une facturation par requête analysée avec des tarifs dégressifs mais élevés. Dans l’exemple public d’AWS, 15 millions de requêtes analysées par ATP produisent plus de 8 000 dollars de frais Fraud Control.&lt;/p&gt;

&lt;p&gt;ATP apporte des capacités beaucoup plus avancées. Il ne répond simplement pas au même besoin économique. Quand une action Challenge ciblée, un rate limiting et quelques signaux réseau suffisent, le rapport efficacité-prix est difficile à battre.&lt;/p&gt;

&lt;h2&gt;
  
  
  En résumé : une même solution avec deux implémentations
&lt;/h2&gt;

&lt;p&gt;La première implémentation tient dans 15 liines Terraform parce que le navigateur navigue vers un document HTML. AWS WAF peut lui répondre directement avec le JavaScript du challenge.&lt;/p&gt;

&lt;p&gt;La seconde demande un petit service frontend parce que l’appel protégé est réalisé avec &lt;code&gt;fetch&lt;/code&gt;. Le challenge doit être exécuté avant la requête, puis son jeton doit être transmis dans un header ou un cookie.&lt;/p&gt;

&lt;p&gt;Dans les deux cas, le backend n’implémente aucune validation de challenge. Il ne stocke pas de clé secrète supplémentaire, n’appelle pas un service externe et ne consomme pas de ressources pour les requêtes rejetées.&lt;/p&gt;

&lt;p&gt;La fonctionnalité AWS WAF Challenge ne remplace pas une stratégie anti-abus complète. Elle permet en revanche de déplacer une opération coûteuse au bon endroit : avant l’application.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>waf</category>
      <category>challenge</category>
      <category>security</category>
    </item>
    <item>
      <title>Cloud Native Buildpack for AWS Lambda PHP (Bref) - technical detail</title>
      <dc:creator>Paul SANTUS</dc:creator>
      <pubDate>Wed, 29 Jul 2026 22:00:00 +0000</pubDate>
      <link>https://dev.to/aws-builders/cloud-native-buildpack-for-aws-lambda-php-bref-technical-detail-4amp</link>
      <guid>https://dev.to/aws-builders/cloud-native-buildpack-for-aws-lambda-php-bref-technical-detail-4amp</guid>
      <description>&lt;p&gt;In first post of this series, I introduced my Cloud Native Buildpack for AWS Lambda PHP (Bref). &lt;/p&gt;

&lt;p&gt;Here I want to provide some technical details on how it works so far. &lt;/p&gt;

&lt;h2&gt;
  
  
  Lambda's /var/task problem
&lt;/h2&gt;

&lt;p&gt;CNB places the application at &lt;code&gt;/workspace&lt;/code&gt;. Lambda requires it at &lt;code&gt;/var/task&lt;/code&gt;. And you can't override &lt;code&gt;LAMBDA_TASK_ROOT&lt;/code&gt;, as it's a reserved environment variable.&lt;/p&gt;

&lt;p&gt;The buildpack runs as a non-root user during certain CNB phases, so it can't write to &lt;code&gt;/var/task&lt;/code&gt; either. The solution is a thin flatten step after the CNB build that copies &lt;code&gt;/workspace&lt;/code&gt; to &lt;code&gt;/var/task&lt;/code&gt; and restores the Bref entrypoint. This is handled automatically by &lt;code&gt;make lambda&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Is it elegant? Not perfectly. But it works, and the developer never sees it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extension installation: three fallback strategies
&lt;/h2&gt;

&lt;p&gt;Bref's extra PHP extensions are published as Docker images (e.g., &lt;code&gt;bref/extra-gd-php-84:3&lt;/code&gt;). Each image is a FROM scratch layer containing just the &lt;code&gt;.so&lt;/code&gt; file and its dependencies under &lt;code&gt;/opt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The buildpack uses a three-tier strategy to install extensions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Method 1: crane&lt;/strong&gt;. It uses go-containerregistry to pull and extract the extension image directly from Docker Hub, no Docker daemon needed. Works great in CI. However, bref-extra only publishes x86_64 images (ARM64 is not supported by the bref-extra project).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Method 2: docker create + cp&lt;/strong&gt;. It falls back to the Docker daemon if crane fails. Same limitation: x86 only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Method 3: Compile from source&lt;/strong&gt;. When neither method works (ARM deployments), the buildpack compiles extensions from source using the Bref build image's toolchain. This is the same approach used in multi-stage Dockerfiles but automated via build recipes.&lt;/p&gt;

&lt;p&gt;The recipes file (&lt;code&gt;lib/extension-recipes.sh&lt;/code&gt;) defines how to build each extension: what system packages to install, what configure flags to pass, where to download external source. Currently supports gd, redis, imagick, amqp, soap, ftp, gmp, pgsql, uuid, yaml, mongodb, calendar, and exif.&lt;/p&gt;

&lt;h2&gt;
  
  
  How bref-extra extensions are built
&lt;/h2&gt;

&lt;p&gt;Looking at the bref-extra repo, each extension uses a two-stage Dockerfile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;bref/build-php-$PHP_VERSION:$BREF_VERSION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;ext&lt;/span&gt;
&lt;span class="c"&gt;# compile the extension...&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;php-config &lt;span class="nt"&gt;--extension-dir&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/redis.so"&lt;/span&gt; /tmp/redis.so
&lt;span class="k"&gt;RUN &lt;/span&gt;php /bref/lib-copy/copy-dependencies.php /tmp/redis.so /tmp/extension-libs

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; scratch&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=ext /tmp/redis.so /opt/bref/extensions/redis.so&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=ext /tmp/ext.ini /opt/bref/etc/php/conf.d/ext-redis.ini&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=ext /tmp/extension-libs /opt/lib&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final published image is literally just &lt;code&gt;/opt&lt;/code&gt; with the compiled artifacts. That's what crane extracts during our build. And for ARM, our recipes replicate that first stage at build time.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>php</category>
      <category>lambda</category>
      <category>buildpack</category>
    </item>
    <item>
      <title>Cloud Native Buildpack for Bref: build your app for Lambda in one command</title>
      <dc:creator>Paul SANTUS</dc:creator>
      <pubDate>Tue, 28 Jul 2026 13:29:23 +0000</pubDate>
      <link>https://dev.to/aws-builders/cloud-native-buildpack-for-bref-build-your-app-for-lambda-in-one-command-24gh</link>
      <guid>https://dev.to/aws-builders/cloud-native-buildpack-for-bref-build-your-app-for-lambda-in-one-command-24gh</guid>
      <description>&lt;p&gt;I've been deploying PHP applications to AWS Lambda with Bref for a while now. Since I'm a Terraform fan, I very quickly parted with &lt;a class="mentioned-user" href="https://dev.to/mnapoli"&gt;@mnapoli&lt;/a&gt;'s serverless.yml approach, in favour a Terraform + container-based Lambdas. &lt;/p&gt;

&lt;p&gt;Here, the Dockerfile approach works, but it's verbose: a typical multi-stage Dockerfile for a Symfony app on Lambda is 100+ lines of repetitive infrastructure code.&lt;/p&gt;

&lt;p&gt;Recently, Cloud Native Buildpacks (CNB) caught my attention. Their promise is to turn source code into container images without a Dockerfile, with built-in layer caching and a standardized build/run separation. The question was: could this work for Lambda?&lt;/p&gt;

&lt;h2&gt;
  
  
  The challenge
&lt;/h2&gt;

&lt;p&gt;Lambda container images have specific requirements that don't align perfectly with the CNB model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lambda expects the app at &lt;code&gt;/var/task&lt;/code&gt; (a reserved, non-overridable path)&lt;/li&gt;
&lt;li&gt;Lambda needs a specific entrypoint (&lt;code&gt;/lambda-entrypoint.sh&lt;/code&gt; for Bref)&lt;/li&gt;
&lt;li&gt;The CNB lifecycle adds its own launcher binary that Lambda doesn't understand&lt;/li&gt;
&lt;li&gt;Bref base images use Amazon Linux with stripped-down package managers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are showstoppers, but they required creative solutions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What my buildpack does
&lt;/h2&gt;

&lt;p&gt;Given a PHP project with &lt;code&gt;bref/bref&lt;/code&gt; in its composer.json, the buildpack handles everything:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Detects the app (composer.json + bref dependency)&lt;/li&gt;
&lt;li&gt;Installs PHP extensions (via crane download or from-source compilation)&lt;/li&gt;
&lt;li&gt;Runs composer install with production optimizations&lt;/li&gt;
&lt;li&gt;Configures OPcache for Lambda cold starts&lt;/li&gt;
&lt;li&gt;Detects Symfony and warms the cache&lt;/li&gt;
&lt;li&gt;Produces a Lambda-ready container image&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The entire configuration is driven by environment variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make lambda &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;TEST_APP_PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;./my-symfony-app &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;LAMBDA_IMAGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;my-app &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;BP_BREF_RUNTIME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;fpm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;BP_BREF_EXTENSIONS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;redis,gd,soap &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;BP_OPCACHE_JIT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare that to 120 lines of multi-stage Dockerfile with extension compilation, vendor optimization, cache warmup, and OPcache tuning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing it for real
&lt;/h2&gt;

&lt;p&gt;I deployed the Symfony Demo application to Lambda using the buildpack. Same app, same database, same environment variables as the existing layer-based deployment. Only change: it's just built with &lt;code&gt;pack build&lt;/code&gt; instead of &lt;code&gt;docker build&lt;/code&gt; and a long multi-stage Docker file.&lt;/p&gt;

&lt;p&gt;It works. The function URL serves the app correctly, Symfony cache is pre-warmed, and the second build reuses all cached layers (vendor, opcache config) making rebuilds near-instant when only application code changes.&lt;/p&gt;

&lt;p&gt;I also tested extension compilation: &lt;code&gt;ftp&lt;/code&gt; and &lt;code&gt;soap&lt;/code&gt; compile successfully from PHP source on ARM64 during the build step, with proper shared library dependency resolution via Bref's &lt;code&gt;copy-dependencies.php&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's not handled by the buildpack
&lt;/h2&gt;

&lt;p&gt;The buildpack builds your app into a Lambda container image. It does not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Set up your Symfony app for Bref (you still need BrefKernel, trusted_proxies, etc.)&lt;/li&gt;
&lt;li&gt;Serve static assets (CSS/JS need CloudFront + S3, same as any Bref deployment)&lt;/li&gt;
&lt;li&gt;Replace your IaC (you still need Terraform/CDK/Serverless to create the Lambda function)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It replaces the Dockerfile and the build process, not the infrastructure or app configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Current state
&lt;/h2&gt;

&lt;p&gt;The buildpack is published and usable today:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/psantus/bref-buildpack" rel="noopener noreferrer"&gt;https://github.com/psantus/bref-buildpack&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docker Hub: &lt;code&gt;psantus/bref-buildpack:0.2.0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;CNB Registry: &lt;code&gt;bref/php-lambda&lt;/code&gt; (registered at registry.buildpacks.io)&lt;/li&gt;
&lt;li&gt;CI: passing on GitHub Actions&lt;/li&gt;
&lt;li&gt;Tested: Symfony Demo running on Lambda ARM64 (Graviton) via function URL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It supports PHP 8.2/8.3/8.4, ARM and x86 architectures, extension compilation from source, all 40+ bref-extra extensions (x86) or compile-from-source (ARM), Symfony auto-detection, composer caching, OPcache/JIT configuration, and vendor shrinking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;buildpacks/tap/pack
git clone https://github.com/psantus/bref-buildpack
&lt;span class="nb"&gt;cd &lt;/span&gt;bref-buildpack
make lambda &lt;span class="nv"&gt;TEST_APP_PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/path/to/your/php/app &lt;span class="nv"&gt;LAMBDA_IMAGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;my-lambda &lt;span class="nv"&gt;BP_BREF_EXTENSIONS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gd,redis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resulting image is ready to push to ECR and deploy to Lambda.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>lambda</category>
      <category>buildpack</category>
      <category>php</category>
    </item>
    <item>
      <title>Serverless Lazy Generation: generate once, cache forever with CloudFront origin groups (+ S3 and Lambda)</title>
      <dc:creator>Paul SANTUS</dc:creator>
      <pubDate>Sat, 25 Jul 2026 14:14:08 +0000</pubDate>
      <link>https://dev.to/aws-builders/serverless-lazy-generation-generate-once-cache-forever-with-cloudfront-origin-groups-s3-and-4098</link>
      <guid>https://dev.to/aws-builders/serverless-lazy-generation-generate-once-cache-forever-with-cloudfront-origin-groups-s3-and-4098</guid>
      <description>&lt;p&gt;Sometimes, your business app may have to be ready to serve millions of assets  generated from your data (invoice PDFs, text-to-speech audio files, etc.), only a small fraction of which will actually be requested by users.&lt;/p&gt;

&lt;p&gt;When such assets generation is expensive, the naive approach - pre-generate every asset - leads to a lot of &lt;strong&gt;wasted compute&lt;/strong&gt;: you generate assets that may never be requested.&lt;/p&gt;

&lt;p&gt;Pre-generation may also cause &lt;strong&gt;bottlenecks&lt;/strong&gt; (adding 1,000 new articles means synthesizing 1,000 audio files before they're available), &lt;strong&gt;storage bloat&lt;/strong&gt; (you're paying to store assets that may never see a single request), and &lt;strong&gt;staleness&lt;/strong&gt; (when content changes, you need a pipeline to detect and regenerate affected assets).&lt;/p&gt;

&lt;p&gt;What if you could flip this model? Generate assets &lt;strong&gt;only when requested&lt;/strong&gt;, cache them permanently, and never generate the same asset twice.&lt;/p&gt;

&lt;p&gt;In this post, I'll walk through a complete serverless architecture that uses CloudFront's native failover mechanism to implement lazy audio generation with Amazon Polly. The same pattern applies to image thumbnails, PDF rendering, video transcoding, or any expensive asset generation.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjdkis0qv5bcsucl2wtj7.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjdkis0qv5bcsucl2wtj7.webp" alt="Solution Architecture" width="800" height="407"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Overview: The CloudFront Origin Group Pattern
&lt;/h2&gt;

&lt;p&gt;The key insight is that CloudFront Origin Groups support &lt;strong&gt;automatic failover&lt;/strong&gt;. You configure a primary origin (S3) and a failover origin (API Gateway + Lambda). When the primary returns a 403 or 404, CloudFront transparently routes the request to the failover origin: no client-side retry, no custom logic.&lt;/p&gt;

&lt;p&gt;This gives us a clean separation:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;S3&lt;/strong&gt; (primary origin)&lt;/td&gt;
&lt;td&gt;Serves cached audio files. Acts as a permanent cache.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;API Gateway + Lambda&lt;/strong&gt; (failover origin)&lt;/td&gt;
&lt;td&gt;Generates audio on-demand when S3 returns 404.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Amazon Polly&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Converts text to speech (the expensive operation).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CloudFront&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Edge caching + origin failover orchestration.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The beauty is that once Lambda generates an audio file and stores it in S3, all subsequent requests are served directly from S3 (or CloudFront's edge cache). Lambda never executes again for that asset.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;(full-disclosure: from this point onwards, the post is AI-generated)&lt;/p&gt;

&lt;h3&gt;
  
  
  First Request (Cache Miss)
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4fnt8sa63f6rmma0k9n4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4fnt8sa63f6rmma0k9n4.png" alt="Sequence diagram" width="800" height="872"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Client&lt;/strong&gt; requests &lt;code&gt;https://d1234.cloudfront.net/audio/welcome.mp3&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudFront&lt;/strong&gt; checks its edge cache — miss. Routes to the Origin Group.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;S3&lt;/strong&gt; (primary origin) returns &lt;strong&gt;404&lt;/strong&gt; — the file doesn't exist yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudFront&lt;/strong&gt; detects the 404 and fails over to the secondary origin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API Gateway&lt;/strong&gt; receives the request and invokes &lt;strong&gt;Lambda&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lambda&lt;/strong&gt; reads the source text from &lt;code&gt;s3://bucket/texts/welcome.txt&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lambda&lt;/strong&gt; calls &lt;strong&gt;Amazon Polly&lt;/strong&gt; to synthesize speech from the text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lambda&lt;/strong&gt; stores the generated MP3 at &lt;code&gt;s3://bucket/audio/welcome.mp3&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lambda&lt;/strong&gt; returns the MP3 binary (base64-encoded) with &lt;code&gt;Cache-Control: public, max-age=31536000&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudFront&lt;/strong&gt; caches the response at the edge and returns it to the client.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Second Request (Cache Hit)
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Client&lt;/strong&gt; requests &lt;code&gt;https://d1234.cloudfront.net/audio/welcome.mp3&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudFront&lt;/strong&gt; checks its edge cache — &lt;strong&gt;hit&lt;/strong&gt;. Returns immediately.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the edge cache has expired:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;CloudFront&lt;/strong&gt; routes to the Origin Group.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;S3&lt;/strong&gt; (primary origin) returns &lt;strong&gt;200&lt;/strong&gt; — the file exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudFront&lt;/strong&gt; caches and returns the response. Lambda is never invoked.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Key Components
&lt;/h2&gt;

&lt;p&gt;Below I detail the components of the solution I share in &lt;a href="https://github.com/psantus/serverless-lazy-generation" rel="noopener noreferrer"&gt;my sample GitHub repo&lt;/a&gt;. My use case is for Text-to-Speech generation with Polly, but you can adapt it to your own.&lt;/p&gt;

&lt;h3&gt;
  
  
  CloudFront Origin Group
&lt;/h3&gt;

&lt;p&gt;The Origin Group is the orchestration layer. It defines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;primary origin&lt;/strong&gt; (S3 with OAC) for serving cached content&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;failover origin&lt;/strong&gt; (API Gateway) for on-demand generation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failover criteria&lt;/strong&gt;: HTTP 403 and 404 status codes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is infrastructure-level routing — no application code needed to handle caching logic.&lt;/p&gt;

&lt;h3&gt;
  
  
  S3 as a Permanent Cache
&lt;/h3&gt;

&lt;p&gt;S3 serves dual purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Source storage&lt;/strong&gt;: Text files live at &lt;code&gt;texts/{filename}.txt&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache storage&lt;/strong&gt;: Generated audio lives at &lt;code&gt;audio/{filename}.mp3&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The S3 bucket is not publicly accessible. CloudFront accesses it via Origin Access Control (OAC), and Lambda accesses it via IAM role permissions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lambda as the Generator
&lt;/h3&gt;

&lt;p&gt;The Lambda function is lean — its only job is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the source text from S3&lt;/li&gt;
&lt;li&gt;Call Polly to synthesize audio&lt;/li&gt;
&lt;li&gt;Store the result back in S3&lt;/li&gt;
&lt;li&gt;Return the audio to the caller&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It includes a belt-and-suspenders check: before calling Polly, it verifies the audio doesn't already exist in S3 (handling race conditions where multiple simultaneous first-requests hit Lambda).&lt;/p&gt;

&lt;h3&gt;
  
  
  Amazon Polly for TTS
&lt;/h3&gt;

&lt;p&gt;Polly provides neural and standard voices for text-to-speech synthesis. In this architecture, we use the standard engine with the Joanna voice and MP3 output format. Polly charges per character synthesized — another reason lazy generation saves money.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benefits
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Zero cold starts for cached content.&lt;/strong&gt; Once generated, audio is served from S3 or CloudFront's edge — sub-10ms latency worldwide. Lambda cold starts only affect the very first request for each asset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Infinite scalability.&lt;/strong&gt; S3 and CloudFront scale to any traffic level without configuration. The generation layer (Lambda) only handles the small fraction of requests that are cache misses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pay only for generation once.&lt;/strong&gt; Each audio file is synthesized exactly once. After that, you're paying only S3 storage ($0.023/GB/month) and CloudFront data transfer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CloudFront edge caching.&lt;/strong&gt; Popular content is served from 400+ edge locations worldwide. Your users get sub-50ms response times regardless of where the origin bucket lives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero maintenance.&lt;/strong&gt; No cron jobs, no batch pipelines, no cache invalidation logic. The system is self-healing — if you delete a cached file, it regenerates on next request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost Analysis
&lt;/h2&gt;

&lt;p&gt;Let's compare pre-generation vs. lazy generation for a platform with 10,000 text articles:&lt;/p&gt;

&lt;h3&gt;
  
  
  Pre-Generation Approach
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Polly: 10,000 articles × 2,000 chars avg&lt;/td&gt;
&lt;td&gt;$16.00 (standard voice)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 storage: 10,000 × 500KB avg&lt;/td&gt;
&lt;td&gt;$0.12/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda compute: 10,000 invocations × 5s avg&lt;/td&gt;
&lt;td&gt;$0.83&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total upfront&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$16.95&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You pay this every time content changes and you re-run the batch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lazy Generation Approach (assuming 20% of content is ever requested)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Polly: 2,000 articles × 2,000 chars avg&lt;/td&gt;
&lt;td&gt;$3.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 storage: 2,000 × 500KB avg&lt;/td&gt;
&lt;td&gt;$0.02/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda compute: 2,000 invocations × 5s avg&lt;/td&gt;
&lt;td&gt;$0.17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudFront requests (all 10K articles, cached)&lt;/td&gt;
&lt;td&gt;$0.01&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$3.40&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;80% cost reduction&lt;/strong&gt; — and you never pay for content nobody reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code Walkthrough
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Lambda Handler Highlights
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;

&lt;span class="n"&gt;s3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;polly&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;polly&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;BUCKET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BUCKET_NAME&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/audio/(.+)\.mp3$&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;statusCode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Belt-and-suspenders: check if already generated (race condition guard)
&lt;/span&gt;    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;BUCKET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.mp3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;audio_bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exceptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NoSuchKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Read source text
&lt;/span&gt;        &lt;span class="n"&gt;text_obj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;BUCKET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;texts/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text_obj&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# Synthesize with Polly
&lt;/span&gt;        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;polly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;synthesize_speech&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OutputFormat&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mp3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;VoiceId&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Joanna&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Engine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;standard&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;audio_bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AudioStream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="c1"&gt;# Cache to S3 (non-fatal if this fails)
&lt;/span&gt;        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;BUCKET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.mp3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;audio_bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ContentType&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio/mpeg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;pass&lt;/span&gt;  &lt;span class="c1"&gt;# Audio still returned even if caching fails
&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;statusCode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;headers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio/mpeg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cache-Control&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;public, max-age=31536000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;audio_bytes&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;isBase64Encoded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Terraform Highlights: Origin Group Configuration
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudfront_distribution"&lt;/span&gt; &lt;span class="s2"&gt;"cdn"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# Primary origin: S3 with OAC&lt;/span&gt;
  &lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;domain_name&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;assets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bucket_regional_domain_name&lt;/span&gt;
    &lt;span class="nx"&gt;origin_id&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"s3-assets"&lt;/span&gt;
    &lt;span class="nx"&gt;origin_access_control_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_cloudfront_origin_access_control&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;oac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# Failover origin: API Gateway&lt;/span&gt;
  &lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;domain_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${aws_api_gateway_rest_api.api.id}.execute-api.us-east-1.amazonaws.com"&lt;/span&gt;
    &lt;span class="nx"&gt;origin_id&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"api-fallback"&lt;/span&gt;
    &lt;span class="nx"&gt;origin_path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/live"&lt;/span&gt;
    &lt;span class="nx"&gt;custom_origin_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;http_port&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;
      &lt;span class="nx"&gt;https_port&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt;
      &lt;span class="nx"&gt;origin_protocol_policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https-only"&lt;/span&gt;
      &lt;span class="nx"&gt;origin_ssl_protocols&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"TLSv1.2"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# Origin Group: failover on 403/404&lt;/span&gt;
  &lt;span class="nx"&gt;origin_group&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;origin_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"s3-with-fallback"&lt;/span&gt;
    &lt;span class="nx"&gt;failover_criteria&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;status_codes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;member&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;origin_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"s3-assets"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;member&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;origin_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"api-fallback"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# Route /audio/* through the origin group&lt;/span&gt;
  &lt;span class="nx"&gt;ordered_cache_behavior&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;path_pattern&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/audio/*"&lt;/span&gt;
    &lt;span class="nx"&gt;target_origin_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"s3-with-fallback"&lt;/span&gt;
    &lt;span class="c1"&gt;# Uses CachingOptimized managed policy&lt;/span&gt;
    &lt;span class="nx"&gt;cache_policy_id&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"658327ea-f89d-4fab-a63d-7e88639e58f6"&lt;/span&gt;
    &lt;span class="nx"&gt;viewer_protocol_policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"redirect-to-https"&lt;/span&gt;
    &lt;span class="nx"&gt;allowed_methods&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"HEAD"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;cached_methods&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"HEAD"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key is the &lt;code&gt;origin_group&lt;/code&gt; block — CloudFront handles all failover logic natively.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deployment
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Clone the repository&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;serverless-lazy-generation

&lt;span class="c"&gt;# Add your text files&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Welcome to our platform."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; sample-texts/welcome.txt
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"This is a demo of lazy generation."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; sample-texts/demo.txt

&lt;span class="c"&gt;# Deploy with Terraform&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;terraform
terraform init
terraform plan
terraform apply

&lt;span class="c"&gt;# Note the outputs&lt;/span&gt;
&lt;span class="c"&gt;# cloudfront_url = "https://d1234abcdef.cloudfront.net"&lt;/span&gt;
&lt;span class="c"&gt;# sample_audio_url = "https://d1234abcdef.cloudfront.net/audio/welcome.mp3"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Get the CloudFront URL from Terraform output&lt;/span&gt;
&lt;span class="nv"&gt;CF_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;terraform &lt;span class="nt"&gt;-chdir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;terraform output &lt;span class="nt"&gt;-raw&lt;/span&gt; cloudfront_url&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# First request — triggers generation (slower, ~2-5 seconds)&lt;/span&gt;
&lt;span class="nb"&gt;time &lt;/span&gt;curl &lt;span class="nt"&gt;-o&lt;/span&gt; welcome.mp3 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CF_URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/audio/welcome.mp3"&lt;/span&gt;

&lt;span class="c"&gt;# Second request — served from cache (fast, &amp;lt;100ms)&lt;/span&gt;
&lt;span class="nb"&gt;time &lt;/span&gt;curl &lt;span class="nt"&gt;-o&lt;/span&gt; welcome2.mp3 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CF_URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/audio/welcome.mp3"&lt;/span&gt;

&lt;span class="c"&gt;# Verify the files are identical&lt;/span&gt;
md5 welcome.mp3 welcome2.mp3

&lt;span class="c"&gt;# Check response headers for cache status&lt;/span&gt;
curl &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CF_URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/audio/welcome.mp3"&lt;/span&gt;
&lt;span class="c"&gt;# Look for: X-Cache: Hit from cloudfront&lt;/span&gt;

&lt;span class="c"&gt;# Request a non-existent text (should return 404)&lt;/span&gt;
curl &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CF_URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/audio/nonexistent.mp3"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Extensions: Other Use Cases
&lt;/h2&gt;

&lt;p&gt;The lazy generation pattern isn't limited to audio. Any expensive, deterministic transformation works:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Use Case&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Generator&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Image thumbnails&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Original images in S3&lt;/td&gt;
&lt;td&gt;Lambda + Sharp&lt;/td&gt;
&lt;td&gt;Resized images&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PDF generation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HTML templates + data&lt;/td&gt;
&lt;td&gt;Lambda + Puppeteer&lt;/td&gt;
&lt;td&gt;PDF documents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Video transcoding&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Source video in S3&lt;/td&gt;
&lt;td&gt;Lambda + FFmpeg&lt;/td&gt;
&lt;td&gt;Transcoded video&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Open Graph images&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Article metadata&lt;/td&gt;
&lt;td&gt;Lambda + Canvas&lt;/td&gt;
&lt;td&gt;Social preview images&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Map tiles&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Vector data&lt;/td&gt;
&lt;td&gt;Lambda + Mapbox GL&lt;/td&gt;
&lt;td&gt;Raster tiles&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For each case, the pattern is identical:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;S3 stores the output (acts as cache)&lt;/li&gt;
&lt;li&gt;CloudFront Origin Group fails over to Lambda on 404&lt;/li&gt;
&lt;li&gt;Lambda generates, caches to S3, and returns&lt;/li&gt;
&lt;li&gt;All subsequent requests bypass Lambda entirely&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The serverless lazy generation pattern gives you the best of both worlds: the simplicity of static file serving with the flexibility of on-demand generation. CloudFront Origin Groups handle all the routing logic at the infrastructure level — no caching libraries, no invalidation jobs, no state management.&lt;/p&gt;

&lt;p&gt;The architecture is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Self-healing&lt;/strong&gt;: Delete a cached file, and it regenerates on next request&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost-efficient&lt;/strong&gt;: You only pay to generate content that's actually consumed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Globally fast&lt;/strong&gt;: CloudFront edge caching provides sub-50ms responses worldwide&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero-ops&lt;/strong&gt;: No servers to manage, no batch jobs to monitor, no cache to warm&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whether you're building a content platform, an accessibility layer, or a media processing pipeline, this pattern scales from zero to millions of assets with no code changes.&lt;/p&gt;

&lt;p&gt;The complete source code — Lambda function, Terraform infrastructure, and sample texts — is available in &lt;a href="https://github.com/psantus/serverless-lazy-generation" rel="noopener noreferrer"&gt;the repository&lt;/a&gt;. Deploy it in under 5 minutes and start generating audio on demand.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>serverless</category>
      <category>cloudfront</category>
    </item>
    <item>
      <title>Zipping 15Gb of S3 files in 6s. How the power of community made it possible.</title>
      <dc:creator>Paul SANTUS</dc:creator>
      <pubDate>Thu, 25 Jun 2026 18:23:01 +0000</pubDate>
      <link>https://dev.to/aws-builders/zipping-15gb-of-s3-files-in-11s-how-the-power-of-community-made-it-possible-5fgg</link>
      <guid>https://dev.to/aws-builders/zipping-15gb-of-s3-files-in-11s-how-the-power-of-community-made-it-possible-5fgg</guid>
      <description>&lt;p&gt;In my &lt;a href="https://dev.to/aws-builders/s3-zipper-challenge-a-parallel-zip-assembly-that-beats-the-single-lambda-approach-37gf"&gt;first article&lt;/a&gt;, I showed how parallelizing zip assembly across multiple Lambdas can beat the single-Lambda bandwidth ceiling. I zipped 6.9GB in 35 seconds with just 5 workers.&lt;/p&gt;

&lt;p&gt;Since then, Jérémie published a &lt;a href="https://rustysl.com/fr/blog/beyond-s3-archive-streaming" rel="noopener noreferrer"&gt;follow-up article&lt;/a&gt; where a contributor (&lt;a href="https://github.com/FigmentEngine/demo-s3-archiving/tree/main/contenders/rust/figment-engine" rel="noopener noreferrer"&gt;Fitz&lt;/a&gt;) introduced a brilliant optimization: &lt;code&gt;UploadPartCopy&lt;/code&gt;. Instead of downloading (or even streaming) big files through Lambda just to upload them back into the zip, you can tell S3 to copy them server-side. This halves the bandwidth requirement and brought his single-Lambda solution down to &lt;strong&gt;106 seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I took Fitz's &lt;code&gt;UploadPartCopy&lt;/code&gt; idea and combined it with my parallel approach. Here's what happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from Jérémie and Fitz
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;UploadPartCopy&lt;/code&gt; insight is elegant: since ZIP STORE mode has deterministic offsets, we know exactly where each file's data lands in the final archive. For big files (≥5MB), we can:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write just the &lt;strong&gt;local file header&lt;/strong&gt; (50 bytes) in an &lt;code&gt;UploadPart&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Have S3 copy the &lt;strong&gt;file data&lt;/strong&gt; directly via &lt;code&gt;UploadPartCopy&lt;/code&gt; — no download, no upload, instant&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This means workers barely use any memory or bandwidth for big files. &lt;/p&gt;

&lt;p&gt;Only issue is that S3 multipart upload API requires all segments (except the last one) to be bigger than 5MB. So the local file header needs to be appended to an another file (or group of files). &lt;/p&gt;

&lt;p&gt;My planner Lambda groups small files together until they reach 5MB, appends the LOC header of the next big file, then the worker fires an &lt;code&gt;UploadPartCopy&lt;/code&gt; for that big file's data. &lt;/p&gt;

&lt;p&gt;When we run out of small files, we stream the smallest remaining big file and pair it with (the LOC header then) a copy of the largest remaining one.&lt;/p&gt;

&lt;p&gt;For CRC32 (required in zip headers): files uploaded with modern AWS SDKs already have CRC32 stored as object metadata. A simple &lt;code&gt;HeadObject&lt;/code&gt; call retrieves it — no need to read the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step Functions: three limitations
&lt;/h2&gt;

&lt;p&gt;My original architecture used Step Functions to orchestrate workers. Here's what I hit.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Inline Map caps at ~40 concurrent iterations
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://docs.aws.amazon.com/step-functions/latest/dg/state-map.html#concepts-map-process-modes" rel="noopener noreferrer"&gt;AWS documentation&lt;/a&gt; says the Inline Map state supports "up to 40 concurrent iterations." In practice I saw up to 55, but never more. With 1500 duos to process, Step Functions queued them in batches of 55. &lt;/p&gt;

&lt;p&gt;I switched to &lt;strong&gt;Distributed Map&lt;/strong&gt; which launches Express child workflow executions. All 1120 iterations started within 2 seconds. Problem solved? Not quite.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Distributed Map: fast to dispatch, slow to collect
&lt;/h3&gt;

&lt;p&gt;With Distributed Map, all workers started within 2 seconds. Every single one finished in under 1 second (mostly &lt;code&gt;UploadPartCopy&lt;/code&gt; calls). Total Lambda compute: ~500ms average.&lt;/p&gt;

&lt;p&gt;Yet the Map state took &lt;strong&gt;38 seconds&lt;/strong&gt; to complete.&lt;/p&gt;

&lt;p&gt;The bottleneck? Step Functions' internal machinery for collecting and aggregating results from 1120 Express child executions. I confirmed: all workers started at 10:06:52-53, all finished by 10:06:54, but the Map state didn't report success until 10:07:28. &lt;strong&gt;35 seconds of pure orchestration overhead&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The 256KB payload limit
&lt;/h3&gt;

&lt;p&gt;Step Functions states can pass at most 256KB between them. With 3000 files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The planner's assignment list exceeds 256KB → had to write to S3&lt;/li&gt;
&lt;li&gt;The aggregated worker results exceed 256KB → had to write CRC32s to S3, read them back in the finalizer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This added complexity and latency (the finalizer reading 1500 small S3 files — 29 seconds sequentially, until I parallelized it down to 1.5s).&lt;/p&gt;

&lt;p&gt;After all these fixes, the Step Functions version ran in &lt;strong&gt;41 seconds&lt;/strong&gt; for 3000 × 5MB files. Respectable — 2.5× faster than Jérémie's 106s — but I knew most of that time was Step Functions overhead, not actual work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The final version: direct Lambda invocation
&lt;/h2&gt;

&lt;p&gt;I stripped out Step Functions entirely and wrote a single &lt;strong&gt;orchestrator Lambda&lt;/strong&gt; that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Lists files, computes zip layout (the job of the "planner" Lambda in my StepFunction architecture), and initiates multipart upload (~0.5s)&lt;/li&gt;
&lt;li&gt;Invokes all worker Lambdas &lt;strong&gt;synchronously in parallel&lt;/strong&gt; using goroutines + the Lambda SDK (~0.5s to dispatch)&lt;/li&gt;
&lt;li&gt;Collects results (workers return inline, no S3 round-trip for parts)&lt;/li&gt;
&lt;li&gt;Reads CRC32 files from S3 in parallel, builds central directory, completes multipart upload (~1s)
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Orchestrator Lambda (15min timeout, 1024MB)
    │
    ├─── goroutine → Invoke Worker 1 (sync) → return {parts}
    ├─── goroutine → Invoke Worker 2 (sync) → return {parts}
    ├─── ...
    └─── goroutine → Invoke Worker N (sync) → return {parts}
    │
    └─── All done → Build CD → CompleteMultipartUpload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Lambda SDK's synchronous &lt;code&gt;Invoke&lt;/code&gt; blocks until the worker returns. With 200 concurrent goroutines, all workers are dispatched instantly. No orchestration overhead, no state size limits for the parts (only CRC32s go to S3), no 35-second result aggregation.&lt;/p&gt;

&lt;p&gt;Now the theoretical time is: &lt;code&gt;orchestration time&lt;/code&gt; + &lt;code&gt;time to upload the smallest large file that stays orphan after we pair all large files with groups of small files or single large files&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Results: 3000 × 5MB benchmark
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Jérémie Gen1 (Rust, streaming)&lt;/td&gt;
&lt;td&gt;212s&lt;/td&gt;
&lt;td&gt;Single Lambda, 512MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jérémie Gen2 (Rust, UploadPartCopy)&lt;/td&gt;
&lt;td&gt;106s&lt;/td&gt;
&lt;td&gt;Single Lambda, 640MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;My Step Functions version&lt;/td&gt;
&lt;td&gt;41s&lt;/td&gt;
&lt;td&gt;Distributed Map, 1120 workers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;My orchestrator Lambda&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Direct invoke, ~1500 workers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;6 seconds&lt;/strong&gt; to zip 15GB into a single valid ZIP64 archive. That's a 18× improvement over the optimized single-Lambda approach, and 35× over the original.&lt;/p&gt;

&lt;p&gt;Worker stats:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Max memory: &lt;strong&gt;85 MB&lt;/strong&gt; (I initially allocated 3008MB — massively over-provisioned thanks to &lt;code&gt;UploadPartCopy&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Average duration: &lt;strong&gt;516ms&lt;/strong&gt; per worker&lt;/li&gt;
&lt;li&gt;Max duration: &lt;strong&gt;1035ms&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I learned (round 2)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Step Functions Parallel Map adds seconds, not milliseconds.&lt;/strong&gt; For latency-sensitive fan-out/fan-in, direct Lambda invocation is faster. Step Functions shines when you need retries, visual debugging, long-running workflows, or error handling, or lightning fast step transition speed. This outstanding performance lasts only until you need more than 40 parallel processes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;UploadPartCopy is the killer optimization.&lt;/strong&gt; When most files are ≥5MB, workers barely do any work — they just tell S3 to copy data server-side. Memory stays under 100MB regardless of file sizes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The orchestrator pattern is underrated.&lt;/strong&gt; A single Lambda with goroutines can invoke hundreds of child Lambdas synchronously, collect results, and finalize — all within one execution context. No state machine, no payload limits between states, no aggregation overhead.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Over-parallelization can hurt.&lt;/strong&gt; 1500 separate assignments created more Step Functions overhead than the actual compute. Grouping into fewer, larger batches would have been better for the SFN approach.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Code: &lt;a href="https://github.com/psantus/on-demand-archive-on-s3" rel="noopener noreferrer"&gt;github.com/psantus/on-demand-archive-on-s3&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The repo has both approaches: Step Functions (&lt;code&gt;cmd/planner&lt;/code&gt; + &lt;code&gt;cmd/worker&lt;/code&gt; + &lt;code&gt;cmd/finalizer&lt;/code&gt;) and the orchestrator Lambda (&lt;code&gt;cmd/orchestrator&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Jérémie's challenge repo: &lt;a href="https://github.com/RustyServerless/demo-s3-archiving" rel="noopener noreferrer"&gt;github.com/RustyServerless/demo-s3-archiving&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next?
&lt;/h2&gt;

&lt;p&gt;The theoretical minimum is bounded by Lambda cold start time (~200ms) plus the slowest &lt;code&gt;UploadPart&lt;/code&gt; call (if we lack small files, we may need to upload a large file manually to append another file's LOC to it) plus orchestrator overhead (~500ms). &lt;/p&gt;

&lt;p&gt;Your move, Jérémie 😏&lt;/p&gt;

&lt;p&gt;Edit: with 73.2Gb (15,000 files), my solutions gives quite acceptable performance. Just 20s (probably due to my 1000 account default concurrency, would likely be faster on an unbounded account :D) &lt;/p&gt;

&lt;p&gt;Paul out. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzqe9cg5mt4eqtp2djrwr.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzqe9cg5mt4eqtp2djrwr.gif" alt="Mic drop" width="250" height="132"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>serverless</category>
      <category>s3</category>
      <category>zip</category>
    </item>
    <item>
      <title>AWS Security Agent: 34 Findings in Under 10 Hours. A Real-World Test</title>
      <dc:creator>Paul SANTUS</dc:creator>
      <pubDate>Mon, 08 Jun 2026 12:00:34 +0000</pubDate>
      <link>https://dev.to/aws-builders/aws-security-agent-34-findings-in-under-10-hours-a-real-world-test-2b4p</link>
      <guid>https://dev.to/aws-builders/aws-security-agent-34-findings-in-under-10-hours-a-real-world-test-2b4p</guid>
      <description>&lt;p&gt;Last week, I ran AWS Security Agent against an app I'm building for a client. The app is quite usual: a React front-end, and the backend is powered by a CMS (that's part of my customer's requirements) on top of which I built a custom API. Both run on Lambda, with DSQL as the database layer and quite a lot of AI inside (more on that below). The results were impressive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two scans, overnight
&lt;/h2&gt;

&lt;p&gt;I kicked off both scans in the evening:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code Review&lt;/strong&gt; completed in 1h26m:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For this, I had to grant read access to my application Github repository (you can provide write access to get fixes, but that was a step I wasn't ready to take just yet.)&lt;/li&gt;
&lt;li&gt;18 findings (9 High, 8 Medium, 1 Low) which covered SQL injection, SSRF, XSS, privilege escalation, secret exposure, IAM misconfigurations&lt;/li&gt;
&lt;li&gt;2h42m of agent task time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Penetration Test&lt;/strong&gt; completed in 7h56m:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The pentest was not a full blackbox test. It started with 3 URLs I provided (the app's front-end, the API and the admin app) but I also submitted the repository. &lt;/li&gt;
&lt;li&gt;16 findings (2 Critical, 2 High, 12 Medium)&lt;/li&gt;
&lt;li&gt;29.16 hours of agent task time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By the time I woke up, I had a downloadable 60+ page report with reproduction steps, CVSS scores, and suggested fixes. And a pleasant UI to see results summary, but also findings details, test logs, etc.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl54z6hmy533cm11mlxvh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl54z6hmy533cm11mlxvh.png" alt="AWS Security Agent PenTest result summary" width="799" height="552"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzinmwsj1fofvgi40m7an.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzinmwsj1fofvgi40m7an.png" alt="AWS Security Agent PenTest finding detail" width="799" height="552"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What impressed me
&lt;/h2&gt;

&lt;h3&gt;
  
  
  It thinks like a real Pentester
&lt;/h3&gt;

&lt;p&gt;The agent didn't just scan for known CVEs. It understood my application's architecture and chained vulnerabilities together:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It discovered that some API method had no authentication&lt;/li&gt;
&lt;li&gt;It found that the some Lambda code called by a Step Function this method was triggering constructed a URL with user-supplied (non-protected) &lt;code&gt;lang&lt;/code&gt; parameter&lt;/li&gt;
&lt;li&gt;It crafted a payload using &lt;code&gt;#&lt;/code&gt; fragment to redirect requests to an attacker-controlled domain it owned&lt;/li&gt;
&lt;li&gt;It verified the SSRF by actually receiving DNS callbacks!!&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's a multi-step attack requiring deep understanding of Python's URL parsing, AWS Step Functions workflow, and the application's data flow from API to Lambda to Wikipedia API.&lt;br&gt;
(I provided the repo )&lt;/p&gt;

&lt;h3&gt;
  
  
  SQL injection: genuinely clever
&lt;/h3&gt;

&lt;p&gt;The code review found a SQL injection vector I would never have caught manually. Our DSQL driver had a shortcut: if a value starts with &lt;code&gt;CAST(&lt;/code&gt;, it's passed through unescaped (intended for internal type conversions). The agent traced the full path from user input (&lt;code&gt;POST /api/my-route&lt;/code&gt; body) through the CMS' abstraction layer down to the raw &lt;code&gt;pg_query()&lt;/code&gt; call, proving the injection was reachable.&lt;/p&gt;

&lt;h3&gt;
  
  
  A unexpected category.
&lt;/h3&gt;

&lt;p&gt;I was expecting the Agent to report SSRF, path traversal, etc. One category I didn't expect was "Cost Abuse". Since my app runs on serverless, the Agent also provided valuable insights on path were attackers could make my AWS bill fat, especially via the use of Bedrock, Polly and other AI services. &lt;/p&gt;

&lt;h3&gt;
  
  
  Findings were validated through exploitation
&lt;/h3&gt;

&lt;p&gt;The penetration test didn't just flag theoretical issues. A bug let the CMS "Installation Wizard" accessible even once install once done. AWS Security Agent &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Accessed the install page without auth&lt;/li&gt;
&lt;li&gt;Successfully connected to the production database (DSQL with IAM auth meant empty credentials worked)&lt;/li&gt;
&lt;li&gt;Enumerated installed plugins with exact version numbers&lt;/li&gt;
&lt;li&gt;Documented each step with HTTP requests and responses&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Both approaches brought original content
&lt;/h3&gt;

&lt;p&gt;Despite the white box approach, only half of findings were redundant. The code review caught architectural issues (IAM over-privilege due to several Lambdas sharing the same IAM role, hardcoded secrets, missing log retention) while the pentest found runtime exploitables (auth bypass, path traversal, IDOR). Together they covered more ground than either alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  A word on cost
&lt;/h2&gt;

&lt;p&gt;Warning: AWS Security Agent CAN be expensive (yet cost-effective): at standard pricing of &lt;strong&gt;$50/agent-hour&lt;/strong&gt;, the total would have been:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Code review: 2.7h × $50 = &lt;strong&gt;$135&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Pentest: 29.2h × $50 = &lt;strong&gt;$1,460&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Total: ~$1,595&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For context, a human pentest engagement of equivalent scope (3 URLs, mixed tech stack, 8 hours of active testing) would probably run $8,000–$20,000 and take 1-2 weeks to deliver results.&lt;/p&gt;

&lt;p&gt;But here's the kicker: &lt;strong&gt;AWS Security Agent includes a generous free tier&lt;/strong&gt;. New customers get a 2-month trial with up to &lt;strong&gt;400 pentesting task-hours per month&lt;/strong&gt;. Both my scans (31.8 task-hours total) fit comfortably within that allowance. So the first real-world security audit of my production application cost me exactly $0.&lt;/p&gt;

&lt;h2&gt;
  
  
  From Findings to Fixes
&lt;/h2&gt;

&lt;p&gt;The actionable output let me fix all findings within a single day:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SQL injection&lt;/strong&gt; → removed CAST bypass, added intval() on inputs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSRF&lt;/strong&gt; → URL scheme allowlist + private IP blocking&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth bypass on install page&lt;/strong&gt; → overlay file blocking access&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Path traversal&lt;/strong&gt; → regex validation on URL path parameters&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Task token exposure&lt;/strong&gt; → stripped from API response&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Info disclosure&lt;/strong&gt; → CloudFront response headers policy removes version headers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing logging&lt;/strong&gt; → API Gateway access logs + 30-day retention&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The suggested fixes in the report were specific enough to implement directly, not generic "validate your inputs" advice, but exact code locations and replacement patterns.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few more things
&lt;/h2&gt;

&lt;p&gt;To be exhaustive, I must share that&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it didn't find existing application logic bugs (due to the model being instructed to focus solely on security. Attention is all we need, right?)&lt;/li&gt;
&lt;li&gt;due to our white box nature of our pentest, a couple findings, while technically correct, required knowledge of our deployment model to be exploited. If you need to know the value of a secret "consider-i-m-an-admin" header, then maybe the risk is not high.. but again the agent thinks like security folks, and probably considered lateral movement after log access like a possible path.&lt;/li&gt;
&lt;li&gt;Some findings are CMS upstream issues that I can't fix without modifying vendor code. I submitted findings to their security team. &lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;AWS Security Agent is not a full replacement for security expertise: you still need to understand your architecture to prioritize and fix findings. But as a first pass that runs overnight and produces a professional-grade report? It's remarkably good; literally 0 findings were non-relevant. The multi-step attack chains, the code-level precision, and the actual exploitation validation put it well above traditional SAST/DAST tools.&lt;/p&gt;

&lt;p&gt;For a solo developer or small team shipping on AWS, this is a no-brainer at the free tier. Run it before every major release, fix what it finds, and sleep better.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ai</category>
      <category>security</category>
      <category>agents</category>
    </item>
    <item>
      <title>S3 zipper challenge: a parallel zip assembly that beats the single Lambda approach</title>
      <dc:creator>Paul SANTUS</dc:creator>
      <pubDate>Mon, 01 Jun 2026 21:27:25 +0000</pubDate>
      <link>https://dev.to/aws-builders/s3-zipper-challenge-a-parallel-zip-assembly-that-beats-the-single-lambda-approach-37gf</link>
      <guid>https://dev.to/aws-builders/s3-zipper-challenge-a-parallel-zip-assembly-that-beats-the-single-lambda-approach-37gf</guid>
      <description>&lt;p&gt;I recently read Jérémie Rodon's excellent article &lt;a href="https://rustysl.com/en/blog/s3-on-demand-archive" rel="noopener noreferrer"&gt;On-Demand Archives on S3&lt;/a&gt;, where he describes an elegant Rust solution for zipping 3,000 × 5MB files from S3 within a single Lambda function. &lt;/p&gt;

&lt;p&gt;His approach is impressive: streaming a ZIP archive through a custom Rotating Slab Buffer, saturating bandwidth with concurrent downloads, all within 512MB of RAM. The result: &lt;strong&gt;3 minutes 35 seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I thought it was a good challenge to reach better performance. His article ends with an open invitation: &lt;em&gt;"do you think you can do better with your favorite language?"&lt;/em&gt; Well, my favorite language is not Rust nor Go nor.. however, I'm fluent in serverless ;) so I took a different angle entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Different Approach: Why Not Parallelize the Problem?
&lt;/h2&gt;

&lt;p&gt;Jérémie's constraint was a single Lambda. That's elegant, but it means you're bound by one machine's network bandwidth (~600 Mbps). No matter how perfect your streaming is, physics wins: 15GB at 600 Mbps ≈ 200 seconds minimum.&lt;/p&gt;

&lt;p&gt;My question was: &lt;strong&gt;what if we break that single-machine bottleneck?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The key insight is that ZIP files in STORE mode (no compression) have &lt;strong&gt;deterministic byte offsets&lt;/strong&gt;. Each entry is exactly &lt;code&gt;50 + len(filename) + filesize&lt;/code&gt; bytes (local header + ZIP64 extra field + data). If you know all filenames and sizes upfront, you can pre-calculate exactly where every file will land in the final archive, before downloading a single byte.&lt;/p&gt;

&lt;p&gt;This means independent workers can each build their portion of the zip in parallel, and S3's multipart upload lets them write their chunks independently (parts can be uploaded in any order by different processes sharing the same upload ID).&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Planner Lambda → Step Functions Distributed Map → N Worker Lambdas → Finalizer Lambda
     │                        │ │ │                        │
     │ CreateMultipartUpload  │ │ │ UploadPart (parallel)  │ CompleteMultipartUpload
     ▼                        ▼ ▼ ▼                        ▼
                         S3 Output Bucket
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv0q5pzfth4of1231hcu2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv0q5pzfth4of1231hcu2.png" alt=" " width="294" height="548"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Planner&lt;/strong&gt;: Lists all source files, computes zip byte offsets, initiates multipart upload, divides work into balanced batches (equal data volume per worker).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Workers&lt;/strong&gt; (N concurrent): Each downloads its assigned files, constructs zip local file headers + raw data, computes CRC32 on the fly, streams to S3 as multipart parts.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Finalizer&lt;/strong&gt;: Builds the central directory with real CRC32 values, uploads it as the final part, calls CompleteMultipartUpload.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;With a quota-constrained training account (I had 10 concurrency limit so used only 5 concurrent Lambdas, 3008MB each), zipping &lt;strong&gt;6.9GB across 160 files&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Single Lambda (Jérémie's Rust)&lt;/th&gt;
&lt;th&gt;Parallel (this project)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Approach&lt;/td&gt;
&lt;td&gt;Stream within 1 Lambda&lt;/td&gt;
&lt;td&gt;Fan-out N workers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time (15GB, 3000 files)&lt;/td&gt;
&lt;td&gt;~215s&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Estimated ~10-15s&lt;/strong&gt; with 100+ workers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time (6.9GB, 160 files, 5 workers)&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;35s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory per worker&lt;/td&gt;
&lt;td&gt;512MB&lt;/td&gt;
&lt;td&gt;3008MB (could be lower)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Language&lt;/td&gt;
&lt;td&gt;Rust 🦀&lt;/td&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With a production account (1000 concurrent Lambdas), the 3000 × 5MB scenario would complete in &lt;strong&gt;under 15 seconds&lt;/strong&gt; (each worker handles ~150MB, downloads take ~2s at 600Mbps, upload ~2s). The bottleneck shifts from bandwidth to Lambda cold start (~200ms for Go on ARM64).&lt;/p&gt;

&lt;h2&gt;
  
  
  Tradeoffs
&lt;/h2&gt;

&lt;p&gt;Jérémie's approach is &lt;strong&gt;simpler to deploy&lt;/strong&gt; (one Lambda, no orchestration) and &lt;strong&gt;cheaper per invocation&lt;/strong&gt; (512MB × 215s vs N × 3008MB × few seconds). It's the right choice when you want minimal infrastructure.&lt;/p&gt;

&lt;p&gt;The parallel approach wins on &lt;strong&gt;wall-clock time&lt;/strong&gt;, and dramatically so. It's the right choice when the user is waiting and you want the archive ready in seconds, not minutes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Single Lambda&lt;/th&gt;
&lt;th&gt;Parallel Fan-Out&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Wall-clock time&lt;/td&gt;
&lt;td&gt;Bounded by bandwidth&lt;/td&gt;
&lt;td&gt;Bounded by slowest worker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complexity&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Medium (Step Functions + 3 Lambdas)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per archive&lt;/td&gt;
&lt;td&gt;Lower&lt;/td&gt;
&lt;td&gt;Higher (more Lambda-seconds total)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scalability&lt;/td&gt;
&lt;td&gt;Fixed ceiling (~600Mbps)&lt;/td&gt;
&lt;td&gt;Linear with concurrency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory efficiency&lt;/td&gt;
&lt;td&gt;Excellent (512MB)&lt;/td&gt;
&lt;td&gt;Good (3GB, could optimize)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If I were to use it in prod, there are plenty of room for optimization (our current Lambda used at most 1875mb, well below our allocated 3Gb, we could use Jérémie's streaming optimizations to cut that to by 10). Yet, we'd probably still have some overhead compared to Jeremie's solutions (cold starts, TLS negociations...) and so far it's just a vanity project :)&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;ZIP STORE mode is embarrassingly parallel&lt;/strong&gt;: deterministic offsets mean zero coordination between workers during the data phase.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;S3 multipart upload is the perfect primitive&lt;/strong&gt;: parts uploaded out of order, by different processes, assembled by S3 at the end.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Step Functions Distributed Map&lt;/strong&gt; is ideal for this pattern: it handles fan-out, concurrency limits, retries, and result collection.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The real bottleneck at scale is Lambda concurrency limits&lt;/strong&gt;, not bandwidth or compute. With sufficient concurrency, you can zip 15GB in the time it takes to download one 5MB file.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;The code is at &lt;a href="https://github.com/psantus/on-demand-archive-on-s3" rel="noopener noreferrer"&gt;github.com/psantus/on-demand-archive-on-s3&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And if you want to try Jérémie's challenge with the single-Lambda constraint, his demo project is at &lt;a href="https://github.com/RustyServerless/demo-s3-archiving" rel="noopener noreferrer"&gt;github.com/RustyServerless/demo-s3-archiving&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Both approaches are valid, it just depends on whether you're optimizing for simplicity or speed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the challenge going?
&lt;/h2&gt;

&lt;p&gt;So, « &lt;strong&gt;do you think you can do better with your favorite &lt;del&gt;language&lt;/del&gt; architecture?&lt;/strong&gt;» &lt;/p&gt;

&lt;p&gt;And what does "better" even mean for you? :)&lt;/p&gt;

</description>
      <category>aws</category>
      <category>serverless</category>
      <category>s3</category>
      <category>zip</category>
    </item>
    <item>
      <title>Hackez votre AWS CLI pour ajouter le support CloudShell et transformer votre terminal en bastion</title>
      <dc:creator>Paul SANTUS</dc:creator>
      <pubDate>Fri, 29 May 2026 12:43:23 +0000</pubDate>
      <link>https://dev.to/aws-builders/hackez-votre-aws-cli-pour-ajouter-le-support-cloudshell-et-transformer-votre-terminal-en-bastion-2hoo</link>
      <guid>https://dev.to/aws-builders/hackez-votre-aws-cli-pour-ajouter-le-support-cloudshell-et-transformer-votre-terminal-en-bastion-2hoo</guid>
      <description>&lt;p&gt;J'utilise AWS CloudShell depuis la Console depuis un moment. C'est pratique : un shell pré-authentifié dans votre navigateur, directement dans la Console AWS. Mais je me suis toujours demandé : pourquoi je ne peux pas l'utiliser depuis mon terminal ? Pourquoi n'y a-t-il pas de commande &lt;code&gt;aws cloudshell&lt;/code&gt; ?&lt;/p&gt;

&lt;p&gt;Il s'avère que c'est possible. L'API existe, elle n'est simplement pas publique. Et une fois que vous avez accès à CloudShell en CLI, vous pouvez faire des choses intéressantes avec, comme utiliser un CloudShell attaché à un VPC comme bastion pour atteindre vos instances RDS privées.&lt;/p&gt;

&lt;p&gt;Consultez le &lt;a href="https://github.com/psantus/cloudshell-cli" rel="noopener noreferrer"&gt;dépôt compagnon&lt;/a&gt; en lisant cet article.&lt;/p&gt;

&lt;h2&gt;
  
  
  CloudShell : une API non documentée
&lt;/h2&gt;

&lt;p&gt;AWS CloudShell n'a pas de support officiel SDK ou CLI. Mais la Console doit bien communiquer avec &lt;em&gt;quelque chose&lt;/em&gt;, non ? En regardant ce que fait le navigateur quand vous ouvrez CloudShell, vous pouvez rétro-ingénierer l'API.&lt;/p&gt;

&lt;p&gt;Heureusement, &lt;a href="https://github.com/guyon-it-consulting/cloudshell-boto3" rel="noopener noreferrer"&gt;Jérôme Guyon&lt;/a&gt; a déjà fait ce travail et publié un modèle de service compatible boto3. Son travail a rendu tout cela possible.&lt;/p&gt;

&lt;p&gt;L'API est simple : créer des environnements, les démarrer/arrêter, créer des sessions, uploader/télécharger des fichiers. Le mécanisme de session utilise le protocole WebSocket de SSM sous le capot, ce qui signifie que &lt;code&gt;session-manager-plugin&lt;/code&gt; (le même binaire qui fait tourner &lt;code&gt;aws ssm start-session&lt;/code&gt;) peut se connecter aux sessions CloudShell.&lt;/p&gt;

&lt;h2&gt;
  
  
  Apprendre un nouveau tour à l'AWS CLI
&lt;/h2&gt;

&lt;p&gt;L'AWS CLI a une fonctionnalité peu connue : &lt;code&gt;aws configure add-model&lt;/code&gt;. Donnez-lui un modèle de service JSON, et soudain la CLI connaît un nouveau service. AWS utilise ça en interne pour les previews privées.&lt;/p&gt;

&lt;p&gt;(Le modèle boto3 du dépôt de Jérôme a juste besoin d'un champ &lt;code&gt;"version": "2.0"&lt;/code&gt; ajouté au niveau racine pour devenir compatible CLI.)&lt;/p&gt;

&lt;p&gt;Exécutez :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws configure add-model &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--service-model&lt;/span&gt; file://cloudshell-cli-model.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--service-name&lt;/span&gt; cloudshell
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;C'est tout. Maintenant j'ai &lt;code&gt;aws cloudshell&lt;/code&gt; avec l'auto-complétion et tout :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;aws cloudshell &lt;span class="nb"&gt;help

&lt;/span&gt;AVAILABLE COMMANDS
       create-environment
       create-session
       delete-environment
       describe-environments
       get-environment-status
       start-environment
       stop-environment
       ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Se connecter à CloudShell depuis le terminal
&lt;/h2&gt;

&lt;p&gt;Le workflow est simple :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Créer ou trouver un environnement&lt;/span&gt;
aws cloudshell create-environment &lt;span class="nt"&gt;--region&lt;/span&gt; eu-west-1

&lt;span class="c"&gt;# Attendre qu'il soit RUNNING&lt;/span&gt;
aws cloudshell get-environment-status &lt;span class="nt"&gt;--environment-id&lt;/span&gt; &amp;lt;ID&amp;gt; &lt;span class="nt"&gt;--region&lt;/span&gt; eu-west-1

&lt;span class="c"&gt;# Créer une session et se connecter&lt;/span&gt;
session-manager-plugin &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws cloudshell create-session &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--environment-id&lt;/span&gt; &amp;lt;ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--session-type&lt;/span&gt; TMUX &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tab-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;uuidgen | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'[:upper:]'&lt;/span&gt; &lt;span class="s1"&gt;'[:lower:]'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--q-cli-disabled&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; eu-west-1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'{SessionId:SessionId,TokenValue:TokenValue,StreamUrl:StreamUrl}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; json&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; eu-west-1 StartSession
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Et vous y êtes. Un shell complet sur une instance CloudShell, depuis votre terminal. Pas besoin de navigateur.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le problème des credentials
&lt;/h2&gt;

&lt;p&gt;Il y a un hic. Quand vous utilisez CloudShell depuis la Console, AWS injecte vos credentials automatiquement via un appel API &lt;code&gt;PutCredentials&lt;/code&gt;. Celui-ci utilise votre token de session console (l'auth par cookie de votre connexion navigateur) pour alimenter le endpoint de métadonnées du conteneur en credentials temporaires.&lt;/p&gt;

&lt;p&gt;Quand vous vous connectez par programme, ça ne se fait pas. Le endpoint de credentials du conteneur renvoie une erreur 500. Vous devez injecter les credentials vous-même :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Exécutez localement, puis collez la sortie dans votre session CloudShell&lt;/span&gt;
aws configure export-credentials &lt;span class="nt"&gt;--profile&lt;/span&gt; my-profile &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="nb"&gt;env&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pas idéal, mais ça fonctionne.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le cas d'usage bastion
&lt;/h2&gt;

&lt;p&gt;C'est là que ça devient intéressant. Vous pouvez créer un environnement CloudShell attaché à un VPC :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws cloudshell create-environment &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--environment-name&lt;/span&gt; db-access &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--vpc-config&lt;/span&gt; &lt;span class="s1"&gt;'{
    "VpcId": "vpc-abc123",
    "SubnetIds": ["subnet-private-1"],
    "SecurityGroupIds": ["sg-allowed-by-rds"]
  }'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; eu-west-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mettez-le dans le même security group que celui autorisé par votre RDS, et soudain vous pouvez vous connecter à votre base de données directement depuis le shell :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mysql &lt;span class="nt"&gt;-h&lt;/span&gt; my-instance.xxx.eu-west-1.rds.amazonaws.com &lt;span class="nt"&gt;-u&lt;/span&gt; admin &lt;span class="nt"&gt;-p&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pas d'instance EC2 bastion à maintenir. Pas de clés SSH à gérer. Pas de coût horaire quand vous ne l'utilisez pas (CloudShell est gratuit). L'environnement se suspend après 20 minutes d'inactivité et vous pouvez le maintenir en vie avec &lt;code&gt;aws cloudshell send-heart-beat&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ce qui ne marche pas (et j'ai essayé..)
&lt;/h2&gt;

&lt;p&gt;J'ai passé pas mal de temps à essayer de faire fonctionner CloudShell comme un vrai bastion de port-forwarding, pour pouvoir utiliser des outils locaux comme DBeaver contre un RDS distant à travers lui. Voici ce que j'ai trouvé :&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Le port forwarding basé sur SSM ne fonctionne pas.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;ECS, par exemple, enregistre les conteneurs comme cibles SSM. Son identifiant SSM n'est pas documenté mais une fois qu'on le connaît, ça marche bien, comme je l'ai décrit dans &lt;a href="https://dev.to/aws-builders/access-your-aws-database-using-local-port-forwarding-on-your-ecsfargate-container-4nk4"&gt;un précédent article&lt;/a&gt;. De cette façon vous pouvez lancer &lt;code&gt;aws ssm start-session --document-name AWS-StartPortForwardingSessionToRemoteHost&lt;/code&gt;.&lt;br&gt;
Les notebooks SageMaker ont un comportement similaire.&lt;/p&gt;

&lt;p&gt;Les instances/conteneurs CloudShell ne semblent pas être enregistrés comme instances managées SSM. Ou s'ils le sont, c'est caché et à ce jour, personne chez AWS n'a divulgué le format de leur ID :) J'ai essayé toutes les combinaisons d'ID d'environnement, d'ID de session et de format de préfixe auxquelles j'ai pu penser. Aucune ne fonctionne.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Le port forwarding local à travers le PTY ne fonctionne pas non plus.&lt;/strong&gt; La session est un terminal, pas un flux TCP brut. Vous ne pouvez pas faire passer des données binaires du protocole MySQL à travers. J'ai même essayé de mettre en place un relais ncat à l'intérieur de CloudShell et de tunneler à travers la session. Le relais fonctionne bien en interne, mais il n'y a aucun moyen de l'exposer comme un port TCP local sur votre machine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Le hole punching UDP est théoriquement possible&lt;/strong&gt; mais nécessite que le CloudShell ait accès à internet (NAT Gateway sur son subnet), et même là vous vous battez contre des problèmes de symétrie NAT des deux côtés. J'ai réussi à faire fonctionner STUN depuis CloudShell, mais le hole punch complet est fragile et impraticable pour un usage en production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alors à quoi ça sert ?
&lt;/h2&gt;

&lt;p&gt;Honnêtement, à pas mal de choses :&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Accès rapide à la base de données&lt;/strong&gt; sans maintenir une instance EC2 bastion. Connectez-vous, exécutez vos requêtes, déconnectez-vous. Gratuit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatisation.&lt;/strong&gt; Vous pouvez scripter l'exécution de commandes sur CloudShell via Python + &lt;code&gt;session-manager-plugin&lt;/code&gt;. Utile pour exécuter des choses à l'intérieur d'un VPC sans déployer une Lambda ou une tâche Fargate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Débogage de connectivité réseau.&lt;/strong&gt; Lancez un CloudShell dans une combinaison subnet/SG spécifique et testez ce qui peut atteindre quoi.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transfert de fichiers&lt;/strong&gt; (depuis les environnements publics). Les APIs &lt;code&gt;get-file-upload-urls&lt;/code&gt; et &lt;code&gt;get-file-download-urls&lt;/code&gt; vous donnent des URLs S3 présignées.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;La limitation principale est que vous êtes limité à exécuter des commandes &lt;em&gt;à l'intérieur&lt;/em&gt; du shell. Vous ne pouvez pas l'utiliser comme un tunnel transparent pour vos outils locaux. Pour ça, vous avez toujours besoin d'une instance EC2 avec l'agent SSM, ou d'une tâche ECS avec execute-command activé.&lt;/p&gt;

&lt;h2&gt;
  
  
  Essayez vous-même
&lt;/h2&gt;

&lt;p&gt;J'ai publié le modèle et un script d'exemple ici : &lt;a href="https://github.com/psantus/cloudshell-cli" rel="noopener noreferrer"&gt;github.com/psantus/cloudshell-cli&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;L'installation se fait en une commande. Le tout est un seul fichier JSON qui apprend un nouveau service à votre AWS CLI. Rappelez-vous juste : c'est une API non documentée. AWS peut la modifier ou la casser à tout moment. Ne construisez rien de critique dessus.&lt;/p&gt;

&lt;p&gt;Mais pour un accès VPC rapide depuis votre terminal ? C'est plutôt génial.&lt;/p&gt;

</description>
      <category>cloudshell</category>
      <category>cli</category>
      <category>aws</category>
    </item>
  </channel>
</rss>
