<?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>Spawn a cheap, SSO-Secured, ephemeral AWS Client VPN</title>
      <dc:creator>Paul SANTUS</dc:creator>
      <pubDate>Thu, 01 Oct 2026 10:28:12 +0000</pubDate>
      <link>https://dev.to/aws-builders/spawn-a-cheap-sso-secured-ephemeral-aws-client-vpn-219p</link>
      <guid>https://dev.to/aws-builders/spawn-a-cheap-sso-secured-ephemeral-aws-client-vpn-219p</guid>
      <description>&lt;h2&gt;
  
  
  Releasing a Terraform module for an ephemeral, SSO-secured AWS Client VPN
&lt;/h2&gt;

&lt;p&gt;Today I'm releasing a Terraform module that spins up an ephemeral, SSO-secured AWS Client VPN endpoint. It costs nothing when nobody's using it, your team logs in with the same corporate identity they already have, and you flip it on from a little web page when you need it.&lt;/p&gt;

&lt;p&gt;I built it because I kept running into the same handful of networking problems and I was tired of solving them with always-on boxes and shared secrets (or private PKIs). &lt;/p&gt;

&lt;h2&gt;
  
  
  The problems it solves
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Goodbye bastion host
&lt;/h3&gt;

&lt;p&gt;The classic way to reach a private RDS instance, an ElastiCache cluster, or an internal load balancer is to keep a bastion around and SSH through it. That means a box to patch, keys to rotate, and a standing attack surface that sits there whether anyone uses it or not.&lt;/p&gt;

&lt;p&gt;Once you're on this VPN you're inside the VPC. You talk to private resources directly, no jump host in the middle, no public database endpoint. When you disconnect, there's nothing left running for someone to poke at.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. A fixed egress IP out of your VPC
&lt;/h3&gt;

&lt;p&gt;Plenty of third-party APIs still gate access with an IP allow-list. If your traffic leaves AWS from whatever ephemeral address a Lambda or a container happened to get, you can't satisfy that.&lt;/p&gt;

&lt;p&gt;With a full tunnel, everything you send exits through your VPC's NAT gateway, so it all appears to come from that one Elastic IP. You give the vendor a single address to allow-list and you're done.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Egress from any AWS region
&lt;/h3&gt;

&lt;p&gt;Because you choose which region the VPC lives in, you choose where your traffic appears to come from. That's genuinely useful when you're testing how a service behaves for users in another part of the world, or when you need to reach something that geofilters by country. Stand the module up in eu-west-3 and you look French; stand it up in us-east-1 and you look American. Same setup, different exit door.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to use it
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How it works
&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%2Fmsbbrd6isd2munglrje7.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%2Fmsbbrd6isd2munglrje7.png" alt="Ephemeral VPN Architecture diagram" width="799" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The moving parts fit together like this. You open a small single-page app served from CloudFront and S3. You sign in through the Cognito hosted UI, which federates via SAML to IAM Identity Center, so it's your real company login, not a password someone shared in Slack two years ago. Cognito hands back a JWT.&lt;/p&gt;

&lt;p&gt;That token lets you call an API Gateway endpoint, which is guarded by a Cognito authorizer. Behind it sits a small Lambda that does one job: when you ask it to turn the VPN on, it associates a subnet with the Client VPN endpoint and adds the default route so your traffic can get out. When you turn it off, it tears the association back down.&lt;/p&gt;

&lt;p&gt;The reason this is cheap is the billing model. AWS charges you for the Client VPN endpoint per associated subnet per hour, not for the endpoint merely existing. So the endpoint stays up permanently at no cost, and the one thing that actually bills, the subnet association, only exists while you're using it. An EventBridge rule runs every 15 minutes and checks whether anyone is still connected. If the last reading shows nobody home, it disassociates automatically. Forget to switch it off and it switches itself off for you.&lt;/p&gt;

&lt;p&gt;Here's the portal you actually interact with:&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%2Fcp5d7qdpao3mz6fmoypa.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%2Fcp5d7qdpao3mz6fmoypa.png" alt="VPN portal screenshot" width="800" height="466"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You sign in, hit start, wait for the status to go green, then connect your client. When you're done you hit stop, or you just walk away and let the idle check handle it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Example module configuration
&lt;/h3&gt;

&lt;p&gt;The module is on the Terraform Registry. A minimal call looks like this:&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;module&lt;/span&gt; &lt;span class="s2"&gt;"vpn"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"psantus/ephemeral-vpn/aws"&lt;/span&gt;
  &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 1.0"&lt;/span&gt;

  &lt;span class="nx"&gt;region&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eu-west-3"&lt;/span&gt;
  &lt;span class="nx"&gt;project_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"acme-vpn"&lt;/span&gt;
  &lt;span class="nx"&gt;app_title&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ACME VPN"&lt;/span&gt;

  &lt;span class="c1"&gt;# Bring your own VPC with a NAT gateway and a private subnet&lt;/span&gt;
  &lt;span class="c1"&gt;# that routes 0.0.0.0/0 through it.&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vpc-0123456789abcdef0"&lt;/span&gt;
  &lt;span class="nx"&gt;target_subnet_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"subnet-0123456789abcdef0"&lt;/span&gt;

  &lt;span class="c1"&gt;# SSO via IAM Identity Center, federated through Cognito.&lt;/span&gt;
  &lt;span class="nx"&gt;auth_mode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"federated"&lt;/span&gt;

  &lt;span class="c1"&gt;# Metadata XML from your three IDC custom SAML apps&lt;/span&gt;
  &lt;span class="c1"&gt;# (VPN client, self-service portal, Cognito).&lt;/span&gt;
  &lt;span class="nx"&gt;client_saml_metadata_file&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${path.module}/idc/client.xml"&lt;/span&gt;
  &lt;span class="nx"&gt;portal_saml_metadata_file&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${path.module}/idc/portal.xml"&lt;/span&gt;
  &lt;span class="nx"&gt;cognito_saml_metadata_file&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${path.module}/idc/cognito.xml"&lt;/span&gt;

  &lt;span class="nx"&gt;cognito_domain_prefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"acme-vpn"&lt;/span&gt;

  &lt;span class="c1"&gt;# Optional: a pretty URL for the web app.&lt;/span&gt;
  &lt;span class="nx"&gt;dns_hosted_zone_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"example.com"&lt;/span&gt;
  &lt;span class="nx"&gt;dns_name&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vpn"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You bring a VPC that already has a NAT gateway and a private subnet, plus three IAM Identity Center SAML apps (one for the VPN client, one for the self-service portal, one for Cognito). Drop in their metadata files and apply. The server certificate is generated for you by Terraform, so there's no openssl dance and no private key sitting on your laptop.&lt;/p&gt;

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

&lt;p&gt;The module is public and ready to use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Terraform Registry: &lt;code&gt;psantus/ephemeral-vpn/aws&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Source: &lt;a href="https://github.com/psantus/terraform-aws-ephemeral-vpn" rel="noopener noreferrer"&gt;https://github.com/psantus/terraform-aws-ephemeral-vpn&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If it saves you from standing up yet another bastion, or from explaining to a vendor why your source IP keeps changing, don't hesitate &lt;a href="https://github.com/sponsors/psantus/" rel="noopener noreferrer"&gt;buy me a coffee ;)&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;Give it a spin!&lt;/p&gt;

</description>
      <category>aws</category>
      <category>vpn</category>
      <category>sso</category>
      <category>terraform</category>
    </item>
    <item>
      <title>aws console : ouvrir la Console AWS depuis le terminal, en une commande</title>
      <dc:creator>Paul SANTUS</dc:creator>
      <pubDate>Tue, 22 Sep 2026 08:36:50 +0000</pubDate>
      <link>https://dev.to/aws-builders/aws-console-ouvrir-la-console-aws-depuis-le-terminal-en-une-commande-hkn</link>
      <guid>https://dev.to/aws-builders/aws-console-ouvrir-la-console-aws-depuis-le-terminal-en-une-commande-hkn</guid>
      <description>&lt;p&gt;Quand on gère des dizaines de comptes et de rôles AWS, ouvrir la Console est un rituel pénible : retrouver le bon profil, cliquer pour se connecter, recommencer pour changer de compte. J'ai fini par packager le raccourci que j'utilise au quotidien en une commande toute simple :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws console mon-profil
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Regardez-le en action :&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/84xrC8crIek" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Et voilà, la Console s'ouvre dans le navigateur, connectée au bon compte. Ça s'installe en une commande et ça s'utilise comme une sous-commande native de l'AWS CLI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;Pré-requis : &lt;strong&gt;AWS CLI v2&lt;/strong&gt;, &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;python3&lt;/code&gt;, et des profils SSO / assume-role.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; aws-console-url.tar.gz &lt;span class="se"&gt;\&lt;/span&gt;
  https://github.com/psantus/aws-console-url/archive/refs/tags/v1.1.0.tar.gz
&lt;span class="nb"&gt;tar &lt;/span&gt;xzf aws-console-url.tar.gz
&lt;span class="nb"&gt;cd &lt;/span&gt;aws-console-url-1.0.0
./install.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;L'installeur enregistre deux alias équivalents dans &lt;code&gt;~/.aws/cli/alias&lt;/code&gt; : &lt;code&gt;aws console&lt;/code&gt; (court) et &lt;code&gt;aws console-url&lt;/code&gt; (explicite).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws console mon-profil &lt;span class="nt"&gt;--print&lt;/span&gt;   &lt;span class="c"&gt;# affiche l'URL au lieu d'ouvrir le navigateur&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  À l'usage
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws console mon-profil                    &lt;span class="c"&gt;# ouvre la console, loué avec le profil correspondant&lt;/span&gt;
aws console mon-profil &lt;span class="nt"&gt;--print&lt;/span&gt;            &lt;span class="c"&gt;# affiche juste l'URL de connexion&lt;/span&gt;
aws console mon-profil &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="c"&gt;# force la région d'atterrissage&lt;/span&gt;
aws console mon-profil &lt;span class="nt"&gt;--browser&lt;/span&gt; &lt;span class="s2"&gt;"Safari"&lt;/span&gt; &lt;span class="c"&gt;# choisir le navigateur&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Par défaut, l'URL s'ouvre dans votre navigateur système (macOS, Linux et WSL/Git Bash sous Windows sont gérés). Pour en fixer un :&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="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_CONSOLE_BROWSER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Google Chrome"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Atterrir directement sur un service
&lt;/h2&gt;

&lt;p&gt;Un deuxième argument (ou &lt;code&gt;--service&lt;/code&gt;) vous emmène droit sur la console d'un service, pas seulement la page d'accueil :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws console mon-profil ec2        &lt;span class="c"&gt;# ouvre directement la console EC2&lt;/span&gt;
aws console mon-profil s3         &lt;span class="c"&gt;# S3 (console globale)&lt;/span&gt;
aws console mon-profil lambda     &lt;span class="c"&gt;# Lambda dans la bonne région&lt;/span&gt;
aws console mon-profil &lt;span class="nt"&gt;--service&lt;/span&gt; rds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La plupart des services suivent &lt;code&gt;/&amp;lt;service&amp;gt;/home&lt;/code&gt; ; quelques cas particuliers (s3, iam, route53, billing globaux ; stepfunctions → states) sont gérés automatiquement. Pour tout deep-link exotique, &lt;code&gt;--destination &amp;lt;url&amp;gt;&lt;/code&gt; reste disponible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le multi-session, gratuitement
&lt;/h2&gt;

&lt;p&gt;La Console AWS permet d'être connecté à &lt;strong&gt;jusqu'à 5 comptes simultanément&lt;/strong&gt; dans le même navigateur. L'outil l'active automatiquement : chaque profil s'ouvre dans &lt;strong&gt;sa propre session&lt;/strong&gt; au lieu d'écraser la précédente.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws console compte-a    &lt;span class="c"&gt;# session A&lt;/span&gt;
aws console compte-b    &lt;span class="c"&gt;# session B, en parallèle : les deux restent connectées&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La première fois, la Console affiche une invite « Enable multi-session » : un clic, une fois par navigateur, et c'est réglé. (&lt;code&gt;--no-multi&lt;/code&gt; pour revenir au comportement classique mono-session.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Le trio d'alias shell : &lt;code&gt;al&lt;/code&gt; / &lt;code&gt;ap&lt;/code&gt; / &lt;code&gt;ac&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Pour aller encore plus vite au quotidien, dans le fichier &lt;code&gt;shell/aws-helpers.sh&lt;/code&gt; je fournis trois fonctions à sourcer dans votre &lt;code&gt;~/.zshrc&lt;/code&gt; (ou &lt;code&gt;~/.bashrc&lt;/code&gt;) :&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;alias&lt;/th&gt;
&lt;th&gt;fonction&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;al&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;login&lt;/strong&gt; : s'assure qu'une session SSO valide existe (idempotent)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ap&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;profile&lt;/strong&gt; : positionne le &lt;code&gt;AWS_PROFILE&lt;/code&gt; ambiant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ac&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;console&lt;/strong&gt; : ouvre la console (&lt;code&gt;ac &amp;lt;profile&amp;gt;&lt;/code&gt;, ou juste &lt;code&gt;ac&lt;/code&gt; après &lt;code&gt;ap&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;source&lt;/span&gt; /chemin/vers/aws-console-url/shell/aws-helpers.sh
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_DEFAULT_SSO_PROFILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"mon-profil-sso"&lt;/span&gt;   &lt;span class="c"&gt;# optionnel, utilisé par al&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Le workflow type devient :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;al                 &lt;span class="c"&gt;# on s'assure d'être connecté&lt;/span&gt;
ap mon-profil      &lt;span class="c"&gt;# on choisit un profil&lt;/span&gt;
ac                 &lt;span class="c"&gt;# on ouvre sa console (utilise $AWS_PROFILE)&lt;/span&gt;
ac autre-compte    &lt;span class="c"&gt;# ...ou un autre explicitement (multi-session)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ac&lt;/code&gt; accepte aussi bien un profil explicite que le &lt;code&gt;$AWS_PROFILE&lt;/code&gt; posé par &lt;code&gt;ap&lt;/code&gt;, et transmet toutes les options à &lt;code&gt;aws console&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Et pour aller au bout du confort, une &lt;strong&gt;complétion zsh&lt;/strong&gt; (&lt;code&gt;shell/completion.zsh&lt;/code&gt;) complète au &lt;code&gt;TAB&lt;/code&gt; les noms de profils (lus en direct depuis votre config AWS), les services et les options :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ac de&amp;lt;TAB&amp;gt;            &lt;span class="c"&gt;# → dev-readonly  dev-admin  …&lt;/span&gt;
ac mon-profil e&amp;lt;TAB&amp;gt;  &lt;span class="c"&gt;# → ec2  ecs  ecr  efs  events  …&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Comment ça marche ; petit point sécu
&lt;/h2&gt;

&lt;p&gt;Sous le capot, l'outil s'appuie entièrement sur l'AWS CLI et sur le&lt;br&gt;
&lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_enable-console-custom-url.html" rel="noopener noreferrer"&gt;flow de fédération documenté par AWS&lt;/a&gt; :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;aws console &amp;lt;profil&amp;gt;
        │
        ├─ aws configure export-credentials  → credentials temporaires (la CLI gère SSO/STS)
        ├─ aws sts get-caller-identity        → ID de compte
        │
        ├─ GET signin.aws.amazon.com/federation?Action=getSigninToken
        └─ open  signin.aws.amazon.com/federation?Action=login&amp;amp;...   (navigateur)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Il ne réinvente rien : c'est l'AWS CLI qui résout les credentials, et l'outil se contente de construire l'URL de connexion à la Console. &lt;/p&gt;

&lt;p&gt;Accessoirement, ça en fait un outil &lt;strong&gt;100 % auditable&lt;/strong&gt; (~150 lignes de &lt;code&gt;bash&lt;/code&gt;) qui ne lit jamais votre cache de tokens SSO, ne stocke aucun credential et ne contacte que &lt;code&gt;signin.aws.amazon.com&lt;/code&gt; : aucune exfiltration de vos identifiants n'est possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  C'est open source
&lt;/h2&gt;

&lt;p&gt;Le code est sous licence MIT, &lt;a href="https://github.com/psantus/aws-console-url" rel="noopener noreferrer"&gt;sur GitHub&lt;/a&gt;. Si l'outil vous fait gagner du&lt;br&gt;
temps, vous pouvez &lt;a href="https://github.com/sponsors/psantus" rel="noopener noreferrer"&gt;me sponsoriser&lt;/a&gt; ☕.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>console</category>
      <category>terminal</category>
      <category>productivity</category>
    </item>
    <item>
      <title>aws console: open the AWS Console from your terminal, in one command</title>
      <dc:creator>Paul SANTUS</dc:creator>
      <pubDate>Tue, 22 Sep 2026 08:36:47 +0000</pubDate>
      <link>https://dev.to/aws-builders/aws-console-open-the-aws-console-from-your-terminal-in-one-command-41d7</link>
      <guid>https://dev.to/aws-builders/aws-console-open-the-aws-console-from-your-terminal-in-one-command-41d7</guid>
      <description>&lt;p&gt;When you manage dozens of AWS accounts and roles, opening the Console is a tedious ritual: find the right profile, click to sign in, start over to switch accounts.&lt;/p&gt;

&lt;p&gt;I ended up packaging the shortcut I use every day into a dead-simple command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws console my-profile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that's it: the AWS Console opens in your browser, signed in to the right account. &lt;/p&gt;

&lt;p&gt;The tools installs in one command and behaves like a native AWS CLI subcommand. Look at it in action:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/84xrC8crIek" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;Requirements: &lt;strong&gt;AWS CLI v2&lt;/strong&gt;, &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;python3&lt;/code&gt;, and SSO / assume-role profiles.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; aws-console-url.tar.gz &lt;span class="se"&gt;\&lt;/span&gt;
  https://github.com/psantus/aws-console-url/archive/refs/tags/v1.1.0.tar.gz
&lt;span class="nb"&gt;tar &lt;/span&gt;xzf aws-console-url.tar.gz
&lt;span class="nb"&gt;cd &lt;/span&gt;aws-console-url-1.0.0
./install.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The installer registers two equivalent aliases in &lt;code&gt;~/.aws/cli/alias&lt;/code&gt;:&lt;br&gt;
&lt;code&gt;aws console&lt;/code&gt; (short) and &lt;code&gt;aws console-url&lt;/code&gt; (explicit). Verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws console my-profile &lt;span class="nt"&gt;--print&lt;/span&gt;   &lt;span class="c"&gt;# print the URL instead of opening the browser&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Basic use
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws console my-profile                    &lt;span class="c"&gt;# open the account's console&lt;/span&gt;
aws console my-profile &lt;span class="nt"&gt;--print&lt;/span&gt;            &lt;span class="c"&gt;# just print the sign-in URL&lt;/span&gt;
aws console my-profile &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="c"&gt;# force the landing region&lt;/span&gt;
aws console my-profile &lt;span class="nt"&gt;--browser&lt;/span&gt; &lt;span class="s2"&gt;"Safari"&lt;/span&gt; &lt;span class="c"&gt;# pick the browser&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By default the URL opens in your system browser (macOS, Linux, and WSL/Git Bash on Windows are supported). To pin one, on macOS:&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="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_CONSOLE_BROWSER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Google Chrome"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Jump straight to a service
&lt;/h2&gt;

&lt;p&gt;A second argument (or &lt;code&gt;--service&lt;/code&gt;) takes you directly to a service's console, not just the home page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws console my-profile ec2        &lt;span class="c"&gt;# open the EC2 console directly&lt;/span&gt;
aws console my-profile s3         &lt;span class="c"&gt;# S3 (global console)&lt;/span&gt;
aws console my-profile lambda     &lt;span class="c"&gt;# Lambda in the right region&lt;/span&gt;
aws console my-profile &lt;span class="nt"&gt;--service&lt;/span&gt; rds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most services resolve to &lt;code&gt;/&amp;lt;service&amp;gt;/home&lt;/code&gt;; a few special cases (s3, iam, route53, billing are global; stepfunctions → states) are handled automatically. For any exotic deep-link, &lt;code&gt;--destination &amp;lt;url&amp;gt;&lt;/code&gt; is still available.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-session, for free
&lt;/h2&gt;

&lt;p&gt;The AWS Console lets you be signed in to &lt;strong&gt;up to 5 accounts simultaneously&lt;/strong&gt; in the same browser. &lt;/p&gt;

&lt;p&gt;The tool enables this automatically: each profile opens in &lt;strong&gt;its own session&lt;/strong&gt; instead of overwriting the previous one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws console account-a    &lt;span class="c"&gt;# session A&lt;/span&gt;
aws console account-b    &lt;span class="c"&gt;# session B, in parallel — both stay signed in&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first time, the Console shows an "Enable multi-session" prompt: one click, once per browser, and you're set. (&lt;code&gt;--no-multi&lt;/code&gt; to go back to classic single-session behavior.)&lt;/p&gt;

&lt;h2&gt;
  
  
  BONUS : my personal shell alias trio: &lt;code&gt;al&lt;/code&gt; / &lt;code&gt;ap&lt;/code&gt; / &lt;code&gt;ac&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;To move even faster day to day, a &lt;code&gt;shell/aws-helpers.sh&lt;/code&gt; file provides three functions to source from your &lt;code&gt;~/.zshrc&lt;/code&gt; (or &lt;code&gt;~/.bashrc&lt;/code&gt;):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;alias&lt;/th&gt;
&lt;th&gt;shortcut to&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;al&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;perform aws sso login&lt;/strong&gt;: ensure a valid SSO session exists idempotent)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ap&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;set profile&lt;/strong&gt;: set the ambient &lt;code&gt;AWS_PROFILE&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ac&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;console&lt;/strong&gt;: open the console (&lt;code&gt;ac &amp;lt;profile&amp;gt;&lt;/code&gt;, or just &lt;code&gt;ac&lt;/code&gt; after &lt;code&gt;ap&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;source&lt;/span&gt; /path/to/aws-console-url/shell/aws-helpers.sh
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_DEFAULT_SSO_PROFILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"my-sso-profile"&lt;/span&gt;   &lt;span class="c"&gt;# optional, used by al&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The typical workflow becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;al                 &lt;span class="c"&gt;# make sure you're logged in&lt;/span&gt;
ap my-profile      &lt;span class="c"&gt;# pick a profile&lt;/span&gt;
ac                 &lt;span class="c"&gt;# open its console (uses $AWS_PROFILE)&lt;/span&gt;
ac other-account   &lt;span class="c"&gt;# ...or a different one explicitly (multi-session)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ac&lt;/code&gt; accepts either an explicit profile or the &lt;code&gt;$AWS_PROFILE&lt;/code&gt; set by &lt;code&gt;ap&lt;/code&gt;, and passes all options through to &lt;code&gt;aws console&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And for maximum comfort, a &lt;strong&gt;zsh completion&lt;/strong&gt; &lt;code&gt;shell/completion.zsh&lt;/code&gt;) tab-completes profile names (read live from your AWS config), services and options:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ac de&amp;lt;TAB&amp;gt;            &lt;span class="c"&gt;# → dev-readonly  dev-admin  …&lt;/span&gt;
ac my-profile e&amp;lt;TAB&amp;gt;  &lt;span class="c"&gt;# → ec2  ecs  ecr  efs  events  …&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How it works; a note on security
&lt;/h2&gt;

&lt;p&gt;Under the hood, the tool relies entirely on the AWS CLI and on the&lt;br&gt;
&lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_enable-console-custom-url.html" rel="noopener noreferrer"&gt;AWS federation sign-in flow documented by AWS&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;aws console &amp;lt;profile&amp;gt;
        │
        ├─ aws configure export-credentials  → temporary credentials (the CLI handles SSO/STS)
        ├─ aws sts get-caller-identity        → account id
        │
        ├─ GET signin.aws.amazon.com/federation?Action=getSigninToken
        └─ open  signin.aws.amazon.com/federation?Action=login&amp;amp;...   (browser)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reinvents nothing: the AWS CLI resolves the credentials, and the tool just builds the Console sign-in URL. As a nice side effect, that makes it &lt;strong&gt;100% auditable&lt;/strong&gt; (~150 lines of &lt;code&gt;bash&lt;/code&gt;): it never reads your SSO token cache, never stores any credential, and only ever contacts &lt;code&gt;signin.aws.amazon.com&lt;/code&gt;, so there's no room for credentials exfiltration.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's open source
&lt;/h2&gt;

&lt;p&gt;The code is MIT-licensed, &lt;a href="https://github.com/psantus/aws-console-url" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;. If the tool saves you time, you can &lt;a href="https://github.com/sponsors/psantus" rel="noopener noreferrer"&gt;sponsor me&lt;/a&gt; ☕.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>console</category>
      <category>productivity</category>
      <category>opensource</category>
    </item>
    <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>
  </channel>
</rss>
