<?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: AWS Community Builders </title>
    <description>The latest articles on DEV Community by AWS Community Builders  (aws-builders).</description>
    <link>https://dev.to/aws-builders</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%2Forganization%2Fprofile_image%2F2794%2F88da75b6-aadd-4ea1-8083-ae2dfca8be94.png</url>
      <title>DEV Community: AWS Community Builders </title>
      <link>https://dev.to/aws-builders</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aws-builders"/>
    <language>en</language>
    <item>
      <title>Every Rule I Added Made It Worse: How Prompt Bloat Killed My Voice</title>
      <dc:creator>Chidozie Uzoegwu</dc:creator>
      <pubDate>Fri, 14 Aug 2026 14:11:21 +0000</pubDate>
      <link>https://dev.to/aws-builders/every-rule-i-added-made-it-worse-how-prompt-bloat-killed-my-voice-3ekd</link>
      <guid>https://dev.to/aws-builders/every-rule-i-added-made-it-worse-how-prompt-bloat-killed-my-voice-3ekd</guid>
      <description>&lt;p&gt;I measured the system prompt from my old setup last week. It came to &lt;strong&gt;224,833 characters, roughly 56,000 tokens, on every single call.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Ninety percent of that was one block of accumulated rules about how the output should sound.&lt;/p&gt;

&lt;p&gt;I did not set out to build that. Nobody does. It grew one reasonable decision at a time, and by the end it was actively making the output worse, which I spent months blaming on the model.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(That prompt-engineering path is no longer what I run in production. It survives in my codebase as a rollback target, which is exactly why I could still assemble it and measure it.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How a prompt gets to 56,000 tokens
&lt;/h2&gt;

&lt;p&gt;The system generates short-form written content in a specific voice. I started the way most people do: a good frontier model, a carefully written prompt, and iteration.&lt;/p&gt;

&lt;p&gt;The loop that got me here is one every prompt engineer will recognise:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Output comes back with a cliché I hate.&lt;/li&gt;
&lt;li&gt;Add a line to the prompt banning that cliché.&lt;/li&gt;
&lt;li&gt;Output improves. Genuinely, for a while.&lt;/li&gt;
&lt;li&gt;New failure appears. Add another rule.&lt;/li&gt;
&lt;li&gt;Repeat for six months.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each individual addition was justified. Each one was a real observed failure with an obvious textual fix. The categories that accumulated:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Banned phrases&lt;/strong&gt;, specific clichés that kept reappearing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Banned structures&lt;/strong&gt;, sentence shapes that read as machine-written&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opener bans&lt;/strong&gt;, a list of ways it was not allowed to start&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Register rules&lt;/strong&gt;, tone and length and formality per content type&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anti-repetition context&lt;/strong&gt;, the openers and closers used in recent output, injected so it wouldn't repeat itself&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Worked examples&lt;/strong&gt;, retrieved samples showing the target voice&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Meta-rules&lt;/strong&gt;, instructions about which of the above took priority when they conflicted&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last category is the tell. &lt;strong&gt;When your prompt needs rules about how to resolve conflicts between its own rules, the prompt has become a program, and nobody is testing it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Then came the validators
&lt;/h2&gt;

&lt;p&gt;Rules in a prompt are requests, not guarantees. So when a rule failed to hold, I did the obvious thing and enforced it in code afterwards.&lt;/p&gt;

&lt;p&gt;That grew into a stack of post-generation checks: regex to catch a banned sentence shape, a stripper for a punctuation habit, a rejection pass for output that restated its input, a vocabulary blocklist, a fallback for when everything got rejected. At one point I shipped seven of these as a single bundle.&lt;/p&gt;

&lt;p&gt;Each was a patch for something the prompt couldn't reliably enforce. And here's the part worth sitting with: &lt;strong&gt;I was compounding validators to compensate for a weakness in how the model was being steered.&lt;/strong&gt; Every new validator was evidence the prompt wasn't working, and my response was another validator.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom I misread
&lt;/h2&gt;

&lt;p&gt;Quality got worse as the rules accumulated.&lt;/p&gt;

&lt;p&gt;Not dramatically. Gradually. Output got flatter and more cautious, and it started sounding like something written by a committee avoiding mistakes rather than something with a point of view. Which, functionally, is exactly what it was.&lt;/p&gt;

&lt;p&gt;My diagnosis at the time was that the model wasn't good enough. I planned a move to a more expensive tier and put a cost estimate together.&lt;/p&gt;

&lt;p&gt;That would not have fixed anything. A stronger model given 56,000 tokens of conflicting constraints produces more expensive committee output.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual diagnosis
&lt;/h2&gt;

&lt;p&gt;The rules were competing, with each other and with the task.&lt;/p&gt;

&lt;p&gt;A language model attends across the whole context. A prompt is not a config file where each line executes independently. It is context the model weighs all at once. Two hundred lines of prohibitions against a two-line description of the actual job means the overwhelming signal is &lt;em&gt;avoid things&lt;/em&gt;. The model optimises for whatever you have given it the most evidence you care about, and I had spent six months providing evidence that what I cared about was not breaking rules.&lt;/p&gt;

&lt;p&gt;That produces output which breaks no rules and says nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Worse, it doesn't converge.&lt;/strong&gt; Adding a rule to fix flatness makes the ratio worse. The tool I was using to fix the problem was the thing causing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why prompting couldn't solve this one
&lt;/h2&gt;

&lt;p&gt;Here's the part I had underweighted for months.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You can instruct a model toward a style. You cannot make a style native to it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Prompting is instruction. It operates on top of what the model already is. Ask for a voice and you get an impression of that voice, held in place by the instruction, degrading the moment the instruction competes with anything else. The rules were load-bearing scaffolding. Remove them and the voice collapsed. Keep them and they crowded out the task.&lt;/p&gt;

&lt;p&gt;That is the signature of using the wrong technique. If a behaviour has to be re-specified in full on every single call, it does not belong in the prompt. It belongs in the weights.&lt;/p&gt;

&lt;h2&gt;
  
  
  What replaced it
&lt;/h2&gt;

&lt;p&gt;I fine-tuned a model on the voice instead. Later articles in this series cover the how, the cost and the evaluation discipline, but the number relevant here is what happened to the prompt.&lt;/p&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;Assembled system prompt&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prompt-engineering&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;224,833 chars, roughly 56,000 tokens&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fine-tuned model&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6,805 chars, roughly 1,700 tokens&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Difference&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;About 33x smaller&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You can measure your own the same way. Assemble the prompt exactly as your production path does and count it, rather than eyeballing the template file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_system_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sample_input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# your real assembly path
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chars |&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approx tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Break it down by section too. Mine was 90% one block, which is what told me the problem was concentrated rather than spread evenly:&lt;br&gt;
&lt;/p&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;re&lt;/span&gt;
&lt;span class="n"&gt;sections&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&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;split&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;={40,}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(((&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;head&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&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 voice rules didn't move into a shorter prompt. They moved into the model. What is left in the prompt is what a prompt is actually good at: what to write about right now, how long, which mode. Instruction, not identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson I'd give my past self
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Strip before you add.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When output is wrong, the reflex is to append a rule, because appending is easy and feels like progress. But every rule is a permanent tax, paid on every call, forever, competing with every other rule, and never reviewed. I never once removed a rule. I only ever added.&lt;/p&gt;

&lt;p&gt;Three questions I now ask before adding anything to a prompt:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is this instruction, or identity?&lt;/strong&gt; Instruction belongs in the prompt. Identity, meaning a voice or a consistent style or a way of reasoning that must hold every time, belongs in the weights.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is this knowledge?&lt;/strong&gt; Facts the model needs but cannot be expected to hold belong in retrieval, not permanently in the prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What am I removing to make room?&lt;/strong&gt; If the answer is nothing, you are not editing. You are accreting.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rough division that came out of it, and that I would now design around from day one:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Fine-tuning owns identity. Prompting owns instruction. RAG owns knowledge.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Put a job in the wrong one and you will spend months adding rules that make it worse, which is a fairly expensive way to learn where the boundaries are.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>machinelearning</category>
      <category>ai</category>
      <category>promptengineering</category>
    </item>
    <item>
      <title>Why Did AWS Release Yet Another Vector Store?</title>
      <dc:creator>Andres Moreno</dc:creator>
      <pubDate>Fri, 14 Aug 2026 13:32:45 +0000</pubDate>
      <link>https://dev.to/aws-builders/why-did-aws-release-yet-another-vector-store-1pd7</link>
      <guid>https://dev.to/aws-builders/why-did-aws-release-yet-another-vector-store-1pd7</guid>
      <description>&lt;p&gt;When AWS announced DynamoDB Vector Search last week, my first thought was... didn't AWS just launch S3 Vectors?&lt;/p&gt;

&lt;p&gt;I mean, S3 Vectors went GA back in December. It's been less than a year, and now we have another service that stores vectors and does similarity search. I'll be honest, I was a bit puzzled. So I decided to dig in and figure out what's going on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AWS vector landscape
&lt;/h2&gt;

&lt;p&gt;Before we get into DynamoDB Vector Search specifically, let's acknowledge the elephant in the room. AWS already has vector capabilities in a lot of places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Amazon OpenSearch&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Aurora PostgreSQL&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Amazon DocumentDB&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Amazon MemoryDB&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Amazon Neptune&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Amazon S3 Vectors&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Amazon DynamoDB Vector Search&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's seven services! While listing these out the question became even louder in my head: &lt;em&gt;why is AWS giving us another one?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  My initial assumption
&lt;/h2&gt;

&lt;p&gt;My first instinct was to compare DynamoDB Vector Search and S3 Vectors head-to-head. They both store vectors, perform similarity search, support AI workloads, and are serverless. On paper, they sound like the same thing.&lt;/p&gt;

&lt;p&gt;I started going through the documentation for both services trying to figure out which one was "better." I was looking at dimensions supported, distance functions, query limits, the typical comparison stuff.&lt;/p&gt;

&lt;p&gt;And then I realized I was asking the wrong question. The question isn't &lt;em&gt;which vector store is better?&lt;/em&gt;, the question is: &lt;strong&gt;what kind of data do these vectors represent?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That distinction changes everything, it's not about the vectors themselves, it's about what the vectors are attached to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational vectors
&lt;/h2&gt;

&lt;p&gt;Think about the data that lives in your application's hot path:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Users&lt;/strong&gt;: profile embeddings for personalization&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Products&lt;/strong&gt;: catalog embeddings for recommendations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session memory&lt;/strong&gt;: conversation context for AI agents&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recommendations&lt;/strong&gt;: real-time suggestions based on behavior&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fraud detection&lt;/strong&gt;: transaction pattern matching&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are all operational, data that changes frequently, is queried with low latency requirements, lives alongside other application data that your code already reads and writes, and represents your application's state.&lt;/p&gt;

&lt;p&gt;This is where DynamoDB Vector Search makes sense. If your product catalog already lives in DynamoDB, why would you copy those embeddings to a separate vector database? You'd have to build a synchronization pipeline, handle eventual consistency, manage another service, and pay for data movement. With native vector search, you store the embedding right next to the item and query it in the same place with a single write within the same service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Knowledge vectors
&lt;/h2&gt;

&lt;p&gt;Now think about a different category of data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PDFs&lt;/strong&gt;: company documents, research papers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documentation&lt;/strong&gt;: internal wikis, product manuals&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Policies&lt;/strong&gt;: compliance docs, HR handbooks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Support articles&lt;/strong&gt;: knowledge base content&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code&lt;/strong&gt;: repository embeddings for search&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are knowledge assets, they don't change very frequently, they are typically large collections that get ingested in bulk, and power RAG pipelines where an AI retrieves context before generating a response. They don't belong in your application's transactional database because they're used as reference material instead of for the application state.&lt;/p&gt;

&lt;p&gt;This is where S3 Vectors shines. It's purpose-built for storing massive collections of embeddings at the lowest possible cost. It's optimized for the pattern where you embed a ton of documents and then query against them. AWS claims up to 90% cost savings compared to specialized vector databases for these workloads, and for large knowledge bases that makes a big difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters more than pricing
&lt;/h2&gt;

&lt;p&gt;The first thing most people do when two services overlap is compare pricing. And yes, the cost profiles are different. But I think the architectural decision is more important than the price per query.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data locality&lt;/strong&gt;: Your operational vectors should live where your operational data lives. If a user's embedding is stored in DynamoDB alongside their profile, you can update both in a single write avoiding stale data from a syncing pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data ownership&lt;/strong&gt;: Knowledge vectors often come from content that lives in S3 already. Keeping the embeddings close to the source material in S3 Vectors means one fewer system to reason about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Simplicity&lt;/strong&gt;: Every time you add a synchronization pipeline between two services, you add a failure mode, latency, cost, and operational burden. Choosing the right vector store based on what the data represents lets you avoid that complexity entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latency&lt;/strong&gt;: DynamoDB Vector Search delivers single-digit millisecond latency. This is really impactful when you're doing real-time recommendations or fraud detection in the hot path of a user request. S3 Vectors optimizes for throughput and cost on large collections, where a few extra milliseconds won't hurt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why DynamoDB Vector Search exists
&lt;/h2&gt;

&lt;p&gt;Let me be specific about what makes this valuable for operational workloads:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Single logical write&lt;/strong&gt;: Store the embedding alongside the item with a regular PutItem call, without the need for a separate ingestion pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native indexing&lt;/strong&gt;: Create a vector index on an attribute and DynamoDB handles the rest, the same way you would create a new global secondary index. With the added benefit that it automatically scales horizontally as your data grows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metadata filters&lt;/strong&gt;: Narrow search results using your existing DynamoDB attributes. For example, you can search products by similarity but filter to a specific category or marketplace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operational workloads&lt;/strong&gt;: Built for the same access patterns DynamoDB already excels at, that provide high throughput, low latency and predictable performance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No infrastructure&lt;/strong&gt;: Same serverless model DynamoDB users already know.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're already using DynamoDB and want to add semantic search to your application, you no longer need to maintain a separate vector database and the pipeline that feeds it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where S3 Vectors shines
&lt;/h2&gt;

&lt;p&gt;On the other side, S3 Vectors is built for a different set of problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Massive collections&lt;/strong&gt;: Billions of vectors at scale without worrying about provisioning&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise RAG&lt;/strong&gt;: Power your retrieval-augmented generation pipelines with large knowledge bases&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost optimization&lt;/strong&gt;: When you have millions of documents embedded and sitting mostly idle, the cost model of S3 Vectors is hard to beat&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long-lived embeddings&lt;/strong&gt;: Documents that get embedded once and queried many times over months or years&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building a knowledge base for your agents or a documentation search system, S3 Vectors is the natural fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model
&lt;/h2&gt;

&lt;p&gt;Here's how I think about it now:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If your vectors represent...&lt;/th&gt;
&lt;th&gt;Consider&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Users&lt;/td&gt;
&lt;td&gt;DynamoDB Vector Search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Products&lt;/td&gt;
&lt;td&gt;DynamoDB Vector Search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recommendations&lt;/td&gt;
&lt;td&gt;DynamoDB Vector Search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session memory&lt;/td&gt;
&lt;td&gt;DynamoDB Vector Search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fraud signals&lt;/td&gt;
&lt;td&gt;DynamoDB Vector Search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDFs&lt;/td&gt;
&lt;td&gt;S3 Vectors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Documentation&lt;/td&gt;
&lt;td&gt;S3 Vectors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Knowledge bases&lt;/td&gt;
&lt;td&gt;S3 Vectors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Support articles&lt;/td&gt;
&lt;td&gt;S3 Vectors&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The simplest way I can put it: if the data is something your application actively mutates and queries in real-time, it's operational and DynamoDB vector search is the perfect fit. If the data is something you embed once and retrieve many times as context, it's knowledge and you should use S3 Vectors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap up
&lt;/h2&gt;

&lt;p&gt;Going back to the question I started with: why did AWS release another vector store?&lt;/p&gt;

&lt;p&gt;The answer is that AWS isn't just adding more vector databases to the catalog, they're bringing vector capabilities to the storage engines developers already use. DynamoDB users shouldn't have to leave DynamoDB to do similarity search on their operational data, and S3 users shouldn't have to spin up a separate database to search their document embeddings.&lt;/p&gt;

&lt;p&gt;It's not about which service is better, it's about which data model matches your vectors.&lt;/p&gt;

&lt;p&gt;Let me know what you think about this distinction! I'd love to hear how others are thinking about where to put their vectors.&lt;/p&gt;

&lt;p&gt;Andres Moreno&lt;/p&gt;

</description>
      <category>aws</category>
      <category>dynamodb</category>
      <category>ai</category>
    </item>
    <item>
      <title>AWS Managed Microsoft AD - integração com FortiGate</title>
      <dc:creator>Railander Marques</dc:creator>
      <pubDate>Fri, 14 Aug 2026 13:32:14 +0000</pubDate>
      <link>https://dev.to/aws-builders/no-aws-managed-microsoft-ad-integracao-com-fortigate-35i6</link>
      <guid>https://dev.to/aws-builders/no-aws-managed-microsoft-ad-integracao-com-fortigate-35i6</guid>
      <description>&lt;h2&gt;
  
  
  1. Objetivo
&lt;/h2&gt;

&lt;p&gt;Migrar a autenticação LDAP do FortiGate do cliente (hoje em texto claro na porta 389, via túnel VPN) para &lt;strong&gt;LDAPS na porta 636&lt;/strong&gt;, com criptografia TLS ponta a ponta, mantendo o mesmo diretório AWS Managed Microsoft AD.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resultado esperado:&lt;/strong&gt; os DCs gerenciados passam a escutar em 636 com um certificado válido, e o FortiGate valida esse certificado contra a CA que confiamos nele.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Conceitos — o que confunde todo mundo
&lt;/h2&gt;

&lt;h3&gt;
  
  
  2.1 Server-side vs client-side LDAPS
&lt;/h3&gt;

&lt;p&gt;O console do AWS Directory Service tem uma aba de LDAPS com opção de &lt;strong&gt;registrar certificado&lt;/strong&gt;. Ela &lt;strong&gt;não serve para este cenário&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Client-side LDAPS&lt;/th&gt;
&lt;th&gt;Server-side LDAPS (&lt;strong&gt;o nosso caso&lt;/strong&gt;)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Quem é o servidor LDAP&lt;/td&gt;
&lt;td&gt;Um AD self-managed / outro diretório&lt;/td&gt;
&lt;td&gt;O &lt;strong&gt;AWS Managed AD&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quem é o cliente LDAP&lt;/td&gt;
&lt;td&gt;O AWS Managed AD e apps AWS&lt;/td&gt;
&lt;td&gt;O &lt;strong&gt;FortiGate&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;O que se faz no console&lt;/td&gt;
&lt;td&gt;Importa o certificado da CA do outro lado e habilita&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Nada&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Onde está o trabalho&lt;/td&gt;
&lt;td&gt;Console&lt;/td&gt;
&lt;td&gt;Dentro da EC2 de gerência&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ou seja: &lt;strong&gt;não há nada para habilitar, importar ou emitir pelo console do Directory Service.&lt;/strong&gt; A porta 636 sobe sozinha no momento em que os DCs recebem um certificado válido. Todo o trabalho é PKI dentro do Windows.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.2 Por que não dá para subir um &lt;code&gt;.pfx&lt;/code&gt; qualquer
&lt;/h3&gt;

&lt;p&gt;O AWS Managed AD é serviço gerenciado: você não tem RDP nem administrador local nos DCs. A única forma de instalar um certificado neles é o &lt;strong&gt;autoenrollment do Active Directory&lt;/strong&gt; — o próprio DC solicita o certificado a uma CA da floresta e instala sozinho.&lt;/p&gt;

&lt;p&gt;Consequência: a CA precisa ser uma &lt;strong&gt;Microsoft Enterprise CA ingressada no domínio&lt;/strong&gt;. Certificado de CA standalone, de CA pública (DigiCert, Let's Encrypt) ou arquivo avulso &lt;strong&gt;não funcionam&lt;/strong&gt; — não é limitação da AWS, é como o autoenrollment do AD funciona.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.3 A cadeia de confiança
&lt;/h3&gt;

&lt;p&gt;Três certificados diferentes, papéis diferentes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Certificado&lt;/th&gt;
&lt;th&gt;Onde vive&lt;/th&gt;
&lt;th&gt;Quem usa&lt;/th&gt;
&lt;th&gt;Sai da AWS?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Chave privada da CA&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;EC2 de gerência&lt;/td&gt;
&lt;td&gt;Assina os demais&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Nunca&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Certificado do DC&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Store &lt;code&gt;LocalMachine\My&lt;/code&gt; de cada DC&lt;/td&gt;
&lt;td&gt;Apresentado no handshake TLS da 636&lt;/td&gt;
&lt;td&gt;Não (você nem toca nele)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Certificado público da CA&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Exportado em &lt;code&gt;.cer&lt;/code&gt; Base64&lt;/td&gt;
&lt;td&gt;Importado no FortiGate&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Sim — é o único que se envia&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;O FortiGate não envia certificado nenhum para o AD. Ele só precisa confiar em quem assinou o certificado do DC.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;O certificado de VPN que o cliente eventualmente tenha enviado não tem relação com isso. VPN e LDAPS são camadas independentes: a VPN entrega o pacote na VPC, o LDAPS criptografa a sessão LDAP dentro dela.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  3. Arquitetura
&lt;/h2&gt;

&lt;h3&gt;
  
  
  3.1 Topologia
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   REDE DO CLIENTE                    │             AWS — VPC (sua conta)
                                      │
  ┌────────────────────┐              │    ┌──────────── Subnet privada AZ-a ────────────┐
  │     FortiGate      │              │    │  ┌───────────────────────────────────────┐  │
  │                    │              │    │  │ DC1 — AWS Managed Microsoft AD        │  │
  │ Trusted CA store:  │              │    │  │ ENI 10.0.1.10  ·  Windows Server 2019 │  │
  │  └ CORP-ROOT-CA    │              │    │  │ SG: d-xxxxxxxxxx_controllers          │  │
  └─────────┬──────────┘              │    │  └───────────────────────────────────────┘  │
            │                         │    └─────────────────────────────────────────────┘
            │ TCP 636 (LDAPS)         │
            │                         │    ┌──────────── Subnet privada AZ-b ────────────┐
  ┌─────────┴──────────┐   IPsec   ┌──┴──┐ │  ┌───────────────────────────────────────┐  │
  │  Túnel VPN / DX    │◄─────────►│ VGW │ │  │ DC2 — AWS Managed Microsoft AD        │  │
  └────────────────────┘           └──┬──┘ │  │ ENI 10.0.2.10                         │  │
                                      │    │  └───────────────────────────────────────┘  │
                                      │    └─────────────────────────────────────────────┘
                                      │
                                      │    ┌──────────── Subnet privada (gerência) ──────┐
                                      │    │  ┌───────────────────────────────────────┐  │
                                      │    │  │ EC2 Windows de gerência               │  │
                                      │    │  │  · ingressada no domínio              │  │
                                      │    │  │  · RSAT / AD Tools                    │  │
                                      │    │  │  · AD CS — Enterprise Root CA  ◄── chave privada  │
                                      │    │  └───────────────────────────────────────┘  │
                                      │    └─────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3.2 Fluxo 1 — Emissão do certificado (uma vez, automático)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  EC2 gerência                          DC1 / DC2 (gerenciados)
       │                                        │
       │  1. AD CS instalado como Enterprise CA │
       │──── publica na floresta ──────────────►│
       │     (CN=Configuration → Public Key     │
       │      Services → Certification          │
       │      Authorities / NTAuthCertificates) │
       │                                        │
       │                                        │ 2. DC detecta CA + template
       │                                        │    com permissão Autoenroll
       │                                        │
       │  3. Solicitação via RPC/DCOM (MS-ICPR) │
       │◄─────── TCP 135 + portas dinâmicas ────│
       │                                        │
       │  4. CA assina e devolve o certificado  │
       │──────────────────────────────────────► │
       │                                        │
       │                                        │ 5. Instala em LocalMachine\My
       │                                        │    → serviço LDAP abre a 636
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;O passo 3 é o motivo de a AWS pedir liberação ampla entre o SG do diretório e o SG da CA: o enrollment usa RPC com portas dinâmicas, não uma porta fixa.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.3 Fluxo 2 — Autenticação do FortiGate (a cada consulta)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  FortiGate                                          DC (10.0.1.10)
      │                                                    │
      │ 1. TCP SYN :636 ─────────────────────────────────► │
      │ 2. ClientHello ──────────────────────────────────► │
      │ 3. ◄──────── ServerHello + certificado do DC       │
      │                                                    │
      │ 4. Valida: assinado pela CA que confio?            │
      │            CN/SAN bate com o nome que consultei?   │
      │            está dentro da validade?                │
      │                                                    │
      │ 5. Túnel TLS estabelecido ◄──────────────────────► │
      │ 6. LDAP bind (usuário de serviço) ───────────────► │
      │ 7. Search: (sAMAccountName=usuario) ─────────────► │
      │ 8. ◄──────── DN + grupos do usuário                │
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;O passo 4 é onde 90% das integrações quebram — ver seção 9.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.4 Portas
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Origem → Destino&lt;/th&gt;
&lt;th&gt;Porta&lt;/th&gt;
&lt;th&gt;Para quê&lt;/th&gt;
&lt;th&gt;Obrigatória&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FortiGate → DCs&lt;/td&gt;
&lt;td&gt;TCP 636&lt;/td&gt;
&lt;td&gt;LDAPS&lt;/td&gt;
&lt;td&gt;Sim&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FortiGate → DCs&lt;/td&gt;
&lt;td&gt;TCP 389&lt;/td&gt;
&lt;td&gt;LDAP em claro (legado)&lt;/td&gt;
&lt;td&gt;Só até a migração concluir&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DCs → EC2 da CA&lt;/td&gt;
&lt;td&gt;TCP 135 + dinâmicas (49152-65535)&lt;/td&gt;
&lt;td&gt;Autoenrollment RPC&lt;/td&gt;
&lt;td&gt;Sim&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DCs → EC2 da CA&lt;/td&gt;
&lt;td&gt;TCP 445&lt;/td&gt;
&lt;td&gt;CDP/AIA em file share&lt;/td&gt;
&lt;td&gt;Se usar publicação SMB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FortiGate → DCs&lt;/td&gt;
&lt;td&gt;TCP 3269&lt;/td&gt;
&lt;td&gt;LDAPS no Global Catalog&lt;/td&gt;
&lt;td&gt;Só se buscar em múltiplos domínios&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  4. Pré-requisitos
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] EC2 Windows ingressada no domínio do AWS Managed AD (a instância de gerência já existente)&lt;/li&gt;
&lt;li&gt;[ ] Credencial no grupo &lt;code&gt;Admins&lt;/code&gt; ou &lt;code&gt;AWS Delegated Enterprise Certificate Authority Administrators&lt;/code&gt; do diretório (a conta &lt;code&gt;Admin&lt;/code&gt; padrão atende)&lt;/li&gt;
&lt;li&gt;[ ] Conectividade FortiGate → VPC já funcionando (se o 389 funciona hoje, está atendido)&lt;/li&gt;
&lt;li&gt;[ ] Usuário de serviço para bind já existente no domínio&lt;/li&gt;
&lt;li&gt;[ ] Janela de ~1h, sendo até 30 min só de espera pela emissão&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Atenção ao DN dos objetos.&lt;/strong&gt; No AWS Managed AD você não cria objetos em &lt;code&gt;CN=Users,DC=...&lt;/code&gt;. Tudo fica sob a sua OU delegada: &lt;code&gt;OU=Users,OU=corp,DC=corp,DC=empresa,DC=com&lt;/code&gt;. Errar isso é a causa mais comum de bind falhando depois que o TLS já subiu.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  5. Passo 1 — Security Groups (console AWS)
&lt;/h2&gt;

&lt;p&gt;Duas liberações distintas, com finalidades diferentes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;a) FortiGate → DCs (o acesso final)&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;EC2 → Security Groups → selecione &lt;code&gt;d-xxxxxxxxxx_controllers&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Inbound rules → Edit → Add rule

&lt;ul&gt;
&lt;li&gt;Type: &lt;strong&gt;Custom TCP&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Port: &lt;strong&gt;636&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Source: &lt;strong&gt;o mesmo CIDR/IP privado que hoje já libera o 389&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;Nunca use IP público como origem. Os DCs ficam em subnets privadas e não têm IP público — o tráfego chega exclusivamente pelo túnel.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;b) DCs ↔ EC2 da CA (o enrollment)&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;No SG da EC2 de gerência: Inbound → All traffic → Source = SG do diretório&lt;/li&gt;
&lt;li&gt;No SG do diretório: Outbound → All traffic → Destination = SG da EC2 de gerência&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Sem o item (b) o DC nunca consegue pedir o certificado e o LDAPS jamais sobe, mesmo com o AD CS instalado corretamente.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Passo 2 — Instalar a CA
&lt;/h2&gt;

&lt;p&gt;RDP na instância de gerência com a conta &lt;code&gt;Admin&lt;/code&gt; do diretório. PowerShell &lt;strong&gt;como administrador&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Install-WindowsFeature&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ADCS-Cert-Authority&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-IncludeManagementTools&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="n"&gt;Install-AdcsCertificationAuthority&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-CAType&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;EnterpriseRootCA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-CACommonName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CORP-ROOT-CA"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-CryptoProviderName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"RSA#Microsoft Software Key Storage Provider"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-KeyLength&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;2048&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-HashAlgorithmName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;SHA256&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-ValidityPeriod&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Years&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ValidityPeriodUnits&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Force&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;O que cada escolha significa:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;EnterpriseRootCA&lt;/code&gt; — é o que registra a CA na floresta e habilita autoenrollment. &lt;code&gt;StandaloneRootCA&lt;/code&gt; &lt;strong&gt;não funciona&lt;/strong&gt; para este fim.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CACommonName&lt;/code&gt; — nome livre. Não precisa ser o domínio; o AD CS descobre o domínio sozinho porque a instância está ingressada. Escolha algo identificável, porque esse nome vai aparecer no FortiGate do cliente.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ValidityPeriod 10 anos&lt;/code&gt; — validade da CA raiz. Os certificados dos DCs terão 1 ano e renovam automaticamente.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Se o cliente já possui PKI corporativa, o correto é instalar como &lt;strong&gt;EnterpriseSubordinateCA&lt;/strong&gt; e submeter o request à raiz dele — nesse caso o pacote enviado precisa conter a &lt;strong&gt;cadeia completa&lt;/strong&gt;, não só um certificado.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Passo 3 — Template de certificado
&lt;/h2&gt;

&lt;p&gt;Uma Enterprise CA nova já publica por padrão os templates &lt;code&gt;Domain Controller Authentication&lt;/code&gt; e &lt;code&gt;Kerberos Authentication&lt;/code&gt;, e os DCs têm permissão de Autoenroll neles. &lt;strong&gt;Na maioria dos casos a 636 sobe sem nenhuma configuração adicional&lt;/strong&gt; — pule para o Passo 4 e só volte aqui se não subir.&lt;/p&gt;

&lt;p&gt;Caminho oficial da AWS, caso necessário:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Server Manager → Tools → &lt;strong&gt;Certification Authority&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Botão direito em &lt;strong&gt;Certificate Templates&lt;/strong&gt; → Manage&lt;/li&gt;
&lt;li&gt;Botão direito em &lt;strong&gt;Kerberos Authentication&lt;/strong&gt; → &lt;strong&gt;Duplicate Template&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Aba &lt;strong&gt;Compatibility&lt;/strong&gt;: Certification recipient → &lt;strong&gt;Windows 10 / Windows Server 2016&lt;/strong&gt; (os DCs do AWS Managed AD rodam Windows Server 2019)&lt;/li&gt;
&lt;li&gt;Aba &lt;strong&gt;General&lt;/strong&gt;: Template display name → &lt;code&gt;LDAPOverSSL&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Aba &lt;strong&gt;Security&lt;/strong&gt;: selecione &lt;strong&gt;Domain Controllers&lt;/strong&gt; e confirme &lt;strong&gt;Read&lt;/strong&gt;, &lt;strong&gt;Enroll&lt;/strong&gt; e &lt;strong&gt;Autoenroll&lt;/strong&gt; marcados&lt;/li&gt;
&lt;li&gt;OK, feche o console de templates&lt;/li&gt;
&lt;li&gt;Botão direito em &lt;strong&gt;Certificate Templates&lt;/strong&gt; → &lt;strong&gt;New&lt;/strong&gt; → &lt;strong&gt;Certificate Template to Issue&lt;/strong&gt; → selecione &lt;code&gt;LDAPOverSSL&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;O template &lt;code&gt;Kerberos Authentication&lt;/code&gt; é o escolhido porque já traz os EKUs certos (Server Authentication, Client Authentication, Smart Card Logon, KDC Authentication) e preenche o SAN com o FQDN do DC &lt;strong&gt;e&lt;/strong&gt; o FQDN do domínio — é isso que permite ao FortiGate consultar por &lt;code&gt;corp.empresa.com&lt;/code&gt; sem erro de identidade.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Passo 4 — Validar
&lt;/h2&gt;

&lt;p&gt;Aguarde até 30 minutos após o Passo 2/3 e rode na instância de gerência:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$fqdn&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Get-WmiObject&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Win32_ComputerSystem&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Domain&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nx"&gt;Test-NetConnection&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$fqdn&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Port&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;636&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;certutil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-dcinfo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;verify&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Critérios de aceite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;TcpTestSucceeded : True&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;certutil -dcinfo verify&lt;/code&gt; lista os DCs com certificado válido e sem erro de cadeia&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Teste funcional de bind com o &lt;code&gt;ldp.exe&lt;/code&gt; (vem com as AD Tools):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;ldp.exe&lt;/code&gt; → Connection → &lt;strong&gt;Connect&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Server: FQDN do domínio · Port: &lt;strong&gt;636&lt;/strong&gt; · marcar &lt;strong&gt;SSL&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A janela deve retornar os atributos do RootDSE (se aparecer só erro 81 = servidor indisponível, o certificado ainda não foi emitido)&lt;/li&gt;
&lt;li&gt;Connection → &lt;strong&gt;Bind&lt;/strong&gt; com o usuário de serviço, para confirmar que a conta de bind funciona&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Só avance quando os dois passarem. Se falhar aqui, o problema é seu — não adianta enviar nada ao cliente.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Passo 5 — Exportar o material e montar o pacote
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;certutil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ca&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cert&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;C:\ca-der.cer&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;certutil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-encode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;C:\ca-der.cer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;C:\ca-publica-base64.cer&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;certutil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-backupkey&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;C:\backup-ca&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ca-publica-base64.cer&lt;/code&gt; → &lt;strong&gt;é o arquivo que vai para o cliente&lt;/strong&gt;. Base64/PEM é o formato que o FortiGate importa.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;C:\backup-ca&lt;/code&gt; → backup da &lt;strong&gt;chave privada&lt;/strong&gt;. Guarde em local seguro fora da instância (S3 com KMS, cofre de senhas). &lt;strong&gt;Nunca envie ao cliente.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pacote a entregar:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Valor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Certificado da CA&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ca-publica-base64.cer&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Servidor LDAP&lt;/td&gt;
&lt;td&gt;FQDN do domínio, ex. &lt;code&gt;corp.empresa.com&lt;/code&gt; (&lt;strong&gt;não IP&lt;/strong&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IPs dos DCs&lt;/td&gt;
&lt;td&gt;10.0.1.10 e 10.0.2.10 — apenas para DNS/rota, não para configurar como servidor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Porta&lt;/td&gt;
&lt;td&gt;636&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Base DN&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DC=corp,DC=empresa,DC=com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Common Name Identifier&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sAMAccountName&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Usuário de bind&lt;/td&gt;
&lt;td&gt;o mesmo já em uso no 389&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  10. Passo 6 — Lado do cliente (FortiGate)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;GUI&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;System → Certificates → Import → &lt;strong&gt;CA Certificate&lt;/strong&gt; → upload do &lt;code&gt;.cer&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;User &amp;amp; Authentication → LDAP Servers → editar o objeto existente&lt;/li&gt;
&lt;li&gt;Server Port → &lt;code&gt;636&lt;/code&gt; · Secure Connection → marcar · Protocol → &lt;strong&gt;LDAPS&lt;/strong&gt; · Certificate → a CA importada&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test Connectivity&lt;/strong&gt; e &lt;strong&gt;Test User Credentials&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;CLI equivalente&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;config user ldap
    edit "AWS-Managed-AD"
        set server "corp.empresa.com"
        set cnid "sAMAccountName"
        set dn "DC=corp,DC=empresa,DC=com"
        set type regular
        set username "CN=svc_fortigate,OU=Users,OU=corp,DC=corp,DC=empresa,DC=com"
        set password ********
        set port 636
        set secure ldaps
        set ca-cert "CORP-ROOT-CA"
        set server-identity-check enable
    next
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Validação no FortiGate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;diagnose &lt;span class="nb"&gt;test &lt;/span&gt;authserver ldap AWS-Managed-AD &amp;lt;usuario&amp;gt; &amp;lt;senha&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pré-requisito no lado dele:&lt;/strong&gt; o FortiGate precisa &lt;strong&gt;resolver o FQDN do domínio&lt;/strong&gt; pelos DCs do AD. Se o DNS dele não aponta para os DCs, configure &lt;code&gt;set dns-primary&lt;/code&gt; para o IP de um DC ou negocie o uso do FQDN do DC específico.&lt;/p&gt;




&lt;h2&gt;
  
  
  11. Troubleshooting
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sintoma&lt;/th&gt;
&lt;th&gt;Causa provável&lt;/th&gt;
&lt;th&gt;Ação&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Test-NetConnection :636&lt;/code&gt; = False na instância de gerência&lt;/td&gt;
&lt;td&gt;Certificado ainda não emitido&lt;/td&gt;
&lt;td&gt;Aguardar 30 min; conferir SG DCs ↔ CA; conferir se a CA é Enterprise, não Standalone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idem, após 1h&lt;/td&gt;
&lt;td&gt;Template sem Autoenroll para Domain Controllers ou não publicado&lt;/td&gt;
&lt;td&gt;Executar o Passo 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ldp.exe&lt;/code&gt; erro 81&lt;/td&gt;
&lt;td&gt;LDAP não está escutando na 636&lt;/td&gt;
&lt;td&gt;Mesmo diagnóstico acima&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;636 OK interno, FortiGate não conecta&lt;/td&gt;
&lt;td&gt;SG inbound 636 ou rota do túnel&lt;/td&gt;
&lt;td&gt;Conferir origem da regra (IP privado, não público)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Failed to establish SSL connection&lt;/code&gt; no FortiGate&lt;/td&gt;
&lt;td&gt;CA não importada, ou cadeia incompleta (caso subordinada)&lt;/td&gt;
&lt;td&gt;Reenviar cadeia completa&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Erro de certificado apesar da CA importada&lt;/td&gt;
&lt;td&gt;FortiGate apontando para &lt;strong&gt;IP&lt;/strong&gt;; &lt;code&gt;server-identity-check&lt;/code&gt; compara com o CN/SAN&lt;/td&gt;
&lt;td&gt;Usar FQDN, ou &lt;code&gt;set server-identity-check disable&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TLS OK mas bind falha&lt;/td&gt;
&lt;td&gt;DN do usuário de serviço errado&lt;/td&gt;
&lt;td&gt;Usar a OU delegada, não &lt;code&gt;CN=Users&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Funcionou e parou de funcionar ~1 ano depois&lt;/td&gt;
&lt;td&gt;Certificado do DC expirou sem renovar&lt;/td&gt;
&lt;td&gt;Ver seção 12&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Comando de diagnóstico no FortiGate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;diagnose debug application fnbamd &lt;span class="nt"&gt;-1&lt;/span&gt;
diagnose debug &lt;span class="nb"&gt;enable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  12. Operação contínua — o que não pode ser esquecido
&lt;/h2&gt;

&lt;p&gt;Ao instalar a CA na instância de gerência, ela vira um &lt;strong&gt;trust anchor de longo prazo&lt;/strong&gt;. Isso cria obrigações:&lt;/p&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;Risco&lt;/th&gt;
&lt;th&gt;Mitigação&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Certificado do DC expira em 1 ano&lt;/td&gt;
&lt;td&gt;Renovação automática só ocorre se a CA estiver online&lt;/td&gt;
&lt;td&gt;Manter a instância viva e monitorada&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instância descartada / recriada&lt;/td&gt;
&lt;td&gt;Perda da chave privada da CA → sem renovação e sem CRL&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;certutil -backupkey&lt;/code&gt; guardado fora da instância; documentar restore&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CRL expira (padrão: 1 semana)&lt;/td&gt;
&lt;td&gt;Validações de certificado passam a falhar&lt;/td&gt;
&lt;td&gt;Aumentar o intervalo de publicação ou garantir que a CA fica online&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instância desligada por economia&lt;/td&gt;
&lt;td&gt;Enrollment e CRL param&lt;/td&gt;
&lt;td&gt;Não incluir essa instância em schedulers de stop&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Comandos úteis de operação:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;certutil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-CRL&lt;/span&gt;&lt;span class="w"&gt;                                   &lt;/span&gt;&lt;span class="c"&gt;# publica CRL manualmente&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;certutil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-getreg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;CA\CRLPeriod&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt;                  &lt;/span&gt;&lt;span class="c"&gt;# consulta período da CRL&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;certutil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-getreg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;CA\ValidityPeriod&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt;             &lt;/span&gt;&lt;span class="c"&gt;# validade dos certs emitidos&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adicione ao monitoramento: expiração do certificado dos DCs, expiração da CRL e status do serviço &lt;code&gt;CertSvc&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  13. Anexo — por que não usar o AWS Private CA
&lt;/h2&gt;

&lt;p&gt;O AWS Private CA Connector for AD faz o mesmo trabalho de forma gerenciada, sem EC2 e sem chave privada sob sua custódia. É tecnicamente superior, mas custa &lt;strong&gt;USD 400/mês por CA em modo general-purpose&lt;/strong&gt; (o connector em si é gratuito; paga-se a CA e os certificados).&lt;/p&gt;

&lt;p&gt;Para um caso de uso restrito a habilitar LDAPS, o AD CS na instância de gerência já existente resolve com custo marginal zero. O Private CA passa a valer a pena quando há emissão de certificados em escala (usuários, máquinas, mTLS) ou quando a custódia da chave privada em EC2 é inaceitável por compliance.&lt;/p&gt;

&lt;p&gt;Diferenças operacionais relevantes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;AD CS na EC2&lt;/th&gt;
&lt;th&gt;AWS Private CA + Connector&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Custo&lt;/td&gt;
&lt;td&gt;Só a EC2&lt;/td&gt;
&lt;td&gt;~USD 400/mês por CA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chave privada&lt;/td&gt;
&lt;td&gt;Sob sua responsabilidade&lt;/td&gt;
&lt;td&gt;Gerenciada pela AWS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Habilitar cert nos DCs&lt;/td&gt;
&lt;td&gt;Automático via autoenrollment&lt;/td&gt;
&lt;td&gt;Explícito: &lt;em&gt;Actions → Enable domain controller certificates&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Patching / disponibilidade&lt;/td&gt;
&lt;td&gt;Sua responsabilidade&lt;/td&gt;
&lt;td&gt;AWS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tempo até emissão&lt;/td&gt;
&lt;td&gt;Até 30 min&lt;/td&gt;
&lt;td&gt;Até 8 horas&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Referências
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/directoryservice/latest/admin-guide/ms_ad_ldap_server_side.html" rel="noopener noreferrer"&gt;Enabling server-side LDAPS using AWS Managed Microsoft AD&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/blogs/security/how-to-enable-ldaps-for-your-aws-microsoft-ad-directory/" rel="noopener noreferrer"&gt;How to enable server-side LDAPS for your AWS Managed Microsoft AD directory (AWS Security Blog)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/directoryservice/latest/admin-guide/ms_ad_pca_connector.html" rel="noopener noreferrer"&gt;Set up AWS Private CA Connector for AD for AWS Managed Microsoft AD&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/private-ca/pricing/" rel="noopener noreferrer"&gt;AWS Private CA pricing&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>ad</category>
      <category>ldaps</category>
      <category>fortigate</category>
    </item>
    <item>
      <title>Connecting Amazon S3 Tables and Snowflake (via the S3 Tables Iceberg REST Endpoint)</title>
      <dc:creator>Aki</dc:creator>
      <pubDate>Fri, 14 Aug 2026 12:26:11 +0000</pubDate>
      <link>https://dev.to/aws-builders/connecting-amazon-s3-tables-and-snowflake-via-the-s3-tables-iceberg-rest-endpoint-a2g</link>
      <guid>https://dev.to/aws-builders/connecting-amazon-s3-tables-and-snowflake-via-the-s3-tables-iceberg-rest-endpoint-a2g</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Original Japanese article&lt;/strong&gt;: &lt;a href="https://zenn.dev/penginpenguin/articles/fcfb6fa2320976" rel="noopener noreferrer"&gt;Amazon S3 TablesとSnowflakeを連携する（S3 Tables Iceberg RESTエンドポイント経由）&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h1&gt;
  
  
  Introduction
&lt;/h1&gt;

&lt;p&gt;I'm Aki, an AWS Community Builder (&lt;a href="https://x.com/jitepengin" rel="noopener noreferrer"&gt;@jitepengin&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;In an earlier article, I verified how to read from and write to an Iceberg table on S3 Tables using Snowflake's Catalog Integration via the AWS Glue Iceberg REST endpoint.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/aws-builders/connecting-amazon-s3-tables-with-snowflake-29n6"&gt;Connecting Amazon S3 Tables with Snowflake&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That setup routed through Snowflake → the Glue Iceberg REST endpoint → Lake Formation → S3 Tables, and required setting up an IAM policy, a trust policy, registering a Lake Formation data location, and granting permissions across all three levels (Catalog / Database / Table).&lt;/p&gt;

&lt;p&gt;On 2026/8/10, Snowflake's &lt;strong&gt;Amazon S3 Tables Iceberg REST catalog integration&lt;/strong&gt; went GA! This is a new route that connects with SigV4 authentication directly to S3 Tables' native Iceberg REST endpoint, without going through Glue at all.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.snowflake.com/en/release-notes/2026/other/2026-08-10-amazon-s3-tables-iceberg-rest-catalog-integration-ga" rel="noopener noreferrer"&gt;https://docs.snowflake.com/en/release-notes/2026/other/2026-08-10-amazon-s3-tables-iceberg-rest-catalog-integration-ga&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This time, I wanted to put this new route to the test.&lt;/p&gt;

&lt;h1&gt;
  
  
  Test Environment
&lt;/h1&gt;

&lt;p&gt;I reused the same resources as the previous article.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Table bucket: &lt;code&gt;penguin-rest-test&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Namespace: &lt;code&gt;analytics&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Table: &lt;code&gt;daily_sales&lt;/code&gt; (columns &lt;code&gt;sales_date&lt;/code&gt;, &lt;code&gt;amount&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Region: &lt;code&gt;ap-northeast-1&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  Architecture
&lt;/h1&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%2Fyczf5zs340bm7sug2ouf.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%2Fyczf5zs340bm7sug2ouf.png" alt=" " width="800" height="343"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This time's architecture connects the S3 Tables gold layer to Snowflake. The key point is that it uses the Amazon S3 Tables Iceberg REST catalog integration instead of Glue Data Catalog.&lt;/p&gt;

&lt;h1&gt;
  
  
  Differences from the Glue Route (Configuration)
&lt;/h1&gt;

&lt;p&gt;The &lt;code&gt;CATALOG INTEGRATION&lt;/code&gt; parameters differ, as shown below, configured specifically for S3 Tables.&lt;/p&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;Via Glue (previous article)&lt;/th&gt;
&lt;th&gt;Direct to S3 Tables (this article)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CATALOG_API_TYPE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AWS_GLUE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AWS_S3TABLES&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CATALOG_URI&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;glue.&amp;lt;region&amp;gt;.amazonaws.com/iceberg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;s3tables.&amp;lt;region&amp;gt;.amazonaws.com/iceberg&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CATALOG_NAME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;account_id&amp;gt;:s3tablescatalog/&amp;lt;bucket&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;arn:aws:s3tables:&amp;lt;region&amp;gt;:&amp;lt;account_id&amp;gt;:bucket/&amp;lt;bucket&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h1&gt;
  
  
  Differences from the Glue Route (Setup Steps)
&lt;/h1&gt;

&lt;p&gt;Of the steps required in the previous article, the following Lake Formation-related steps simply don't exist here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Registering the Lake Formation data location (&lt;code&gt;register-resource --with-federation --with-privileged-access&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Granting Lake Formation permissions across all three levels (Catalog / Database / Table)&lt;/li&gt;
&lt;li&gt;Adding &lt;code&gt;sts:SetSourceIdentity&lt;/code&gt; / &lt;code&gt;sts:SetContext&lt;/code&gt; to the data access role's trust policy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wanted to make sure "Lake Formation setup really isn't required" is actually true, so I later confirmed this via CloudTrail.&lt;/p&gt;

&lt;h1&gt;
  
  
  Creating the IAM Role
&lt;/h1&gt;

&lt;p&gt;Starting from the previous article's IAM policy, I removed all Lake Formation- and Glue-related actions, and tested with a minimal-privilege policy scoped down to just &lt;code&gt;s3tables:*&lt;/code&gt; actions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AllowS3TablesDirectAccess"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="s2"&gt;"s3tables:GetTableBucket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="s2"&gt;"s3tables:ListNamespaces"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="s2"&gt;"s3tables:GetNamespace"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="s2"&gt;"s3tables:ListTables"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="s2"&gt;"s3tables:GetTable"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="s2"&gt;"s3tables:GetTableMetadataLocation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="s2"&gt;"s3tables:UpdateTableMetadataLocation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="s2"&gt;"s3tables:GetTableData"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="s2"&gt;"s3tables:PutTableData"&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3tables:ap-northeast-1:123456789012:bucket/penguin-rest-test"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3tables:ap-northeast-1:123456789012:bucket/penguin-rest-test/table/*"&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key point is that there's no need to include &lt;code&gt;lakeformation:GetDataAccess&lt;/code&gt; or any &lt;code&gt;glue:*&lt;/code&gt; actions.&lt;/p&gt;

&lt;h1&gt;
  
  
  Creating the Catalog Integration
&lt;/h1&gt;

&lt;p&gt;On the Snowflake side, I created a catalog integration that connects directly to the S3 Tables Iceberg REST endpoint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;CATALOG&lt;/span&gt; &lt;span class="n"&gt;INTEGRATION&lt;/span&gt; &lt;span class="n"&gt;penguin_s3tables_direct_int&lt;/span&gt;
  &lt;span class="n"&gt;CATALOG_SOURCE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ICEBERG_REST&lt;/span&gt;
  &lt;span class="n"&gt;TABLE_FORMAT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ICEBERG&lt;/span&gt;
  &lt;span class="n"&gt;REST_CONFIG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;CATALOG_URI&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'https://s3tables.ap-northeast-1.amazonaws.com/iceberg'&lt;/span&gt;
    &lt;span class="n"&gt;CATALOG_API_TYPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AWS_S3TABLES&lt;/span&gt;
    &lt;span class="n"&gt;ACCESS_DELEGATION_MODE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;VENDED_CREDENTIALS&lt;/span&gt;
    &lt;span class="k"&gt;CATALOG_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'arn:aws:s3tables:ap-northeast-1:123456789012:bucket/penguin-rest-test'&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;REST_AUTHENTICATION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SIGV4&lt;/span&gt;
    &lt;span class="n"&gt;SIGV4_IAM_ROLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'arn:aws:iam::123456789012:role/penguin-s3tables-direct-role'&lt;/span&gt;
    &lt;span class="n"&gt;SIGV4_SIGNING_REGION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ap-northeast-1'&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;ENABLED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;TRUE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compared side by side with the Glue-based article, &lt;code&gt;CATALOG_API_TYPE&lt;/code&gt; changes from &lt;code&gt;AWS_GLUE&lt;/code&gt; to &lt;code&gt;AWS_S3TABLES&lt;/code&gt;, and &lt;code&gt;CATALOG_URI&lt;/code&gt; changes from the Glue Iceberg REST endpoint to S3 Tables' native endpoint. &lt;code&gt;CATALOG_NAME&lt;/code&gt; also now takes the S3 Tables bucket ARN directly, so the previous article's distinctive &lt;code&gt;&amp;lt;account_id&amp;gt;:s3tablescatalog/&amp;lt;bucket&amp;gt;&lt;/code&gt; prefix notation is not used here.&lt;/p&gt;

&lt;h1&gt;
  
  
  Setting Up the Trust Policy
&lt;/h1&gt;

&lt;p&gt;The steps for retrieving Snowflake's IAM user ARN and external ID via &lt;code&gt;DESC CATALOG INTEGRATION&lt;/code&gt;, then setting them on the trust policy of the role specified in &lt;code&gt;SIGV4_IAM_ROLE&lt;/code&gt;, are the same as with the Glue route.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;CATALOG&lt;/span&gt; &lt;span class="n"&gt;INTEGRATION&lt;/span&gt; &lt;span class="n"&gt;penguin_s3tables_direct_int&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"Principal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"AWS"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;AWS_IAM_USER_ARN&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sts:AssumeRole"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"StringEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"sts:ExternalId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;AWS_EXTERNAL_ID&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything up to this point is unchanged from the previous article. The difference shows up from here on: we move straight to table creation with no Lake Formation setup at all.&lt;/p&gt;

&lt;h1&gt;
  
  
  Creating the Table
&lt;/h1&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="n"&gt;ICEBERG&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;GOLD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GOLD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;s3tables_direct_table&lt;/span&gt;
  &lt;span class="k"&gt;CATALOG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'penguin_s3tables_direct_int'&lt;/span&gt;
  &lt;span class="n"&gt;CATALOG_NAMESPACE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'analytics'&lt;/span&gt;
  &lt;span class="n"&gt;CATALOG_TABLE_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'daily_sales'&lt;/span&gt;
  &lt;span class="n"&gt;AUTO_REFRESH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;TRUE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The table was created without any issues, with no Lake Formation data location registration or grants in place.&lt;/p&gt;

&lt;h1&gt;
  
  
  Verifying It Works (Read)
&lt;/h1&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="n"&gt;ICEBERG&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;GOLD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GOLD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;s3tables_direct_table&lt;/span&gt; &lt;span class="n"&gt;REFRESH&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;GOLD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GOLD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;s3tables_direct_table&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj75xywl8lf4iwsd2x2r3.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%2Fj75xywl8lf4iwsd2x2r3.png" alt=" " width="799" height="596"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Data that had already been loaded via Athena was readable from Snowflake without any problem.&lt;/p&gt;

&lt;h1&gt;
  
  
  Confirming the Lake Formation Calls via CloudTrail
&lt;/h1&gt;

&lt;p&gt;This is the heart of this article. I traced through CloudTrail to see exactly which APIs &lt;code&gt;penguin-s3tables-direct-role&lt;/code&gt; was actually calling.&lt;/p&gt;

&lt;p&gt;Looking at the logs right after running the read operation, this role only calls &lt;code&gt;GetTableBucket&lt;/code&gt;, &lt;code&gt;GetNamespace&lt;/code&gt;, &lt;code&gt;ListNamespaces&lt;/code&gt;, &lt;code&gt;GetTable&lt;/code&gt;, &lt;code&gt;ListTables&lt;/code&gt;, and &lt;code&gt;GetTableMetadataLocation&lt;/code&gt; against &lt;code&gt;s3tables.amazonaws.com&lt;/code&gt;, all of them &lt;code&gt;readOnly: true&lt;/code&gt; events.&lt;/p&gt;

&lt;p&gt;Three things sealed it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No &lt;code&gt;invokedBy&lt;/code&gt; field&lt;/strong&gt;&lt;br&gt;
Events from the Glue-based &lt;code&gt;Snowflake_Catalog_Integration&lt;/code&gt; role always carry &lt;code&gt;"invokedBy": "glue.amazonaws.com"&lt;/code&gt;, proof that the resource is being accessed via Glue's service role. Events from the direct role have no such field at all, showing that Snowflake is hitting &lt;code&gt;s3tables.amazonaws.com&lt;/code&gt; directly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;sourceIPAddress&lt;/code&gt; is a real IP&lt;/strong&gt;&lt;br&gt;
Events from the direct role record Snowflake's actual source IP in &lt;code&gt;sourceIPAddress&lt;/code&gt;, whereas Glue-route events show &lt;code&gt;sourceIPAddress: "glue.amazonaws.com"&lt;/code&gt;, the notation for an internal service-to-service call.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Zero &lt;code&gt;lakeformation.amazonaws.com&lt;/code&gt; events&lt;/strong&gt;&lt;br&gt;
Across the entire test window (roughly 30 minutes, several hundred CloudTrail events including both reads and writes), there were &lt;strong&gt;zero&lt;/strong&gt; &lt;code&gt;lakeformation.amazonaws.com&lt;/code&gt; events attributable to &lt;code&gt;penguin-s3tables-direct-role&lt;/code&gt;. During that same window, &lt;code&gt;Snowflake_Catalog_Integration&lt;/code&gt; (the Glue route) generated a large volume of &lt;code&gt;lakeformation:GetDataAccess&lt;/code&gt; calls, so this isn't a case of events happening to not show up in the log; it's a structural fact that the direct route never goes through Lake Formation.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h1&gt;
  
  
  Verifying It Works (Write)
&lt;/h1&gt;

&lt;p&gt;Beyond reads, let's confirm writes work too. Since the IAM policy already includes &lt;code&gt;s3tables:PutTableData&lt;/code&gt;, this can be tested with no additional permission changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;GOLD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GOLD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;s3tables_direct_table&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sales_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-08-14'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;99999&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-08-14'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;88888&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checking from the Athena side, the data inserted from Snowflake was visible without issue.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;"analytics"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"daily_sales"&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;sales_date&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fckiq662pm4535ldstsry.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%2Fckiq662pm4535ldstsry.png" alt=" " width="799" height="258"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I also checked CloudTrail for the write operation, and just as with the read, found no Lake Formation events attributable to &lt;code&gt;penguin-s3tables-direct-role&lt;/code&gt;.&lt;/p&gt;

&lt;h1&gt;
  
  
  Checking Catalog-Linked Database
&lt;/h1&gt;

&lt;p&gt;In the previous article, I verified automatic table discovery via a Glue-based Catalog-Linked Database (CLD), so I wanted to confirm the same works on the direct route.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;penguin_s3tables_direct_linked_db&lt;/span&gt;
  &lt;span class="n"&gt;LINKED_CATALOG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;CATALOG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'penguin_s3tables_direct_int'&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As soon as this was created, &lt;code&gt;daily_sales&lt;/code&gt; and &lt;code&gt;daily_sales2&lt;/code&gt; under the &lt;code&gt;analytics&lt;/code&gt; namespace showed up automatically in the table list, with no DDL required.&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%2Fvh70cs9b5h1ooo2cpn18.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%2Fvh70cs9b5h1ooo2cpn18.png" alt=" " width="800" height="516"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirming Auto-Discovery
&lt;/h2&gt;

&lt;p&gt;I created a new &lt;code&gt;daily_sales3&lt;/code&gt; table from the Athena side, and it was automatically detected on the SnowSight side as well.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- On the Athena side&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;`analytics`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;daily_sales3&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;sale_date&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;product_category&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;sales_amount&lt;/span&gt; &lt;span class="nb"&gt;double&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;PARTITIONED&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sale_date&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;TBLPROPERTIES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'table_type'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'iceberg'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh92evmb6kd5c3qios5hq.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%2Fh92evmb6kd5c3qios5hq.png" alt=" " width="800" height="537"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirming Bidirectional Writes
&lt;/h2&gt;

&lt;p&gt;I inserted into the auto-discovered &lt;code&gt;daily_sales3&lt;/code&gt; from Snowflake, then checked whether it was visible from Athena.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;PENGUIN_S3TABLES_DIRECT_LINKED_DB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;daily_sales3&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sale_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product_category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sales_amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-08-14'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Electronics'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15000&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-08-14'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Clothing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8500&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="s1"&gt;'2026-08-14'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Food'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3200&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi4knlo6v6tco6aub36ap.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%2Fi4knlo6v6tco6aub36ap.png" alt=" " width="799" height="236"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Confirmed without issue on the Athena side. Just like the &lt;code&gt;CREATE ICEBERG TABLE&lt;/code&gt; approach, the CLD approach also works correctly on the direct route.&lt;/p&gt;

&lt;h1&gt;
  
  
  How the Two Routes Compare
&lt;/h1&gt;

&lt;p&gt;Putting the previous article's findings together with this one's:&lt;/p&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;Via Glue (previous article)&lt;/th&gt;
&lt;th&gt;Direct to S3 Tables (this article)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;IAM policy&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;s3tables:*&lt;/code&gt; + &lt;code&gt;lakeformation:GetDataAccess&lt;/code&gt; + &lt;code&gt;glue:*&lt;/code&gt; actions&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;s3tables:*&lt;/code&gt; actions only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lake Formation data location registration&lt;/td&gt;
&lt;td&gt;Required (&lt;code&gt;register-resource --with-federation&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Not required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lake Formation grants (3 levels)&lt;/td&gt;
&lt;td&gt;Required&lt;/td&gt;
&lt;td&gt;Not required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trust policy &lt;code&gt;SetSourceIdentity&lt;/code&gt; / &lt;code&gt;SetContext&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Required&lt;/td&gt;
&lt;td&gt;Not required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LF calls on read (CloudTrail)&lt;/td&gt;
&lt;td&gt;Occur&lt;/td&gt;
&lt;td&gt;Zero&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LF calls on write (CloudTrail)&lt;/td&gt;
&lt;td&gt;Occur&lt;/td&gt;
&lt;td&gt;Zero&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CLD auto-discovery and bidirectional writes&lt;/td&gt;
&lt;td&gt;Succeeded (confirmed in previous article)&lt;/td&gt;
&lt;td&gt;Succeeded (confirmed here)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read/write operations&lt;/td&gt;
&lt;td&gt;Succeeded (confirmed in previous article)&lt;/td&gt;
&lt;td&gt;Succeeded (confirmed here)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The biggest difference is a clear tradeoff: do you want unified governance through Lake Formation, or a simpler permission design?&lt;/p&gt;

&lt;p&gt;If you want to keep things simple, go with the direct S3 Tables route. If you need unified table management, centralized governance controls, and fine-grained access control, the Glue route is the way to go.&lt;/p&gt;

&lt;h1&gt;
  
  
  Conclusion
&lt;/h1&gt;

&lt;p&gt;This time, I used Snowflake's Amazon S3 Tables Iceberg REST catalog integration, which went GA on 2026/8/10, to verify the new Glue-free direct route.&lt;/p&gt;

&lt;p&gt;To summarize:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The new direct route (&lt;code&gt;CATALOG_API_TYPE = AWS_S3TABLES&lt;/code&gt;) has no Lake Formation-related setup steps in the official documentation.&lt;/li&gt;
&lt;li&gt;Even after completely removing all Lake Formation and Glue actions from the IAM policy, both reads and writes worked without any issues.&lt;/li&gt;
&lt;li&gt;Checking CloudTrail confirmed that &lt;code&gt;lakeformation:GetDataAccess&lt;/code&gt; calls from the direct role were zero throughout the entire test window.&lt;/li&gt;
&lt;li&gt;Events from the direct role carry no &lt;code&gt;invokedBy: glue.amazonaws.com&lt;/code&gt; field, and &lt;code&gt;sourceIPAddress&lt;/code&gt; shows a real IP, further confirming that Snowflake accesses the S3 Tables endpoint directly, without going through Glue.&lt;/li&gt;
&lt;li&gt;Catalog-Linked Database auto-discovery and bidirectional reads/writes also worked correctly on the direct route, and no Lake Formation calls occurred during the CLD's background sync either.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With the integration verified in this article, using the S3 Tables Iceberg REST endpoint, no Lake Formation-related permissions are needed at all. If you're willing to forgo unified governance through Lake Formation in exchange for a simpler permission design, this looks like a strong option.&lt;/p&gt;

&lt;p&gt;I hope this article is helpful to anyone considering connecting S3 Tables and Snowflake.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>snowflake</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>How I build a free VPN over AWS CloudShell</title>
      <dc:creator>Jérôme GUYON</dc:creator>
      <pubDate>Fri, 14 Aug 2026 09:51:10 +0000</pubDate>
      <link>https://dev.to/aws-builders/how-i-build-a-free-vpn-over-aws-cloudshell-1kcj</link>
      <guid>https://dev.to/aws-builders/how-i-build-a-free-vpn-over-aws-cloudshell-1kcj</guid>
      <description>&lt;p&gt;From time to time when I'm travelling, mainly in Malaysia which has become kind of a second home 🇲🇾, I face geo-restrictions from CDNs or applications that prevent me from accessing what I need. A badly configured car rental website CDN, French government websites that block non-European IPs. This is very occasional usage, and I don't want to pay for yet another subscription and I also don't trust most VPN vendors with my traffic. As an IT professional, the question became: can I build or host my own for nearly $0?&lt;/p&gt;

&lt;p&gt;That's when I started looking at &lt;a href="https://docs.aws.amazon.com/cloudshell/latest/userguide/welcome.html" rel="noopener noreferrer"&gt;AWS CloudShell&lt;/a&gt;. A free Linux shell in any AWS region, with outbound internet access. I recently published a &lt;a href="https://github.com/guyon-it-consulting/cloudshell-boto3" rel="noopener noreferrer"&gt;blog article about the hidden CloudShell API&lt;/a&gt;, and the next logical step was obvious: what if I could route my laptop's traffic through it?&lt;/p&gt;

&lt;p&gt;I'm calling it &lt;a href="https://github.com/guyon-it-consulting/cloudshell-vpn" rel="noopener noreferrer"&gt;cloudshell-vpn&lt;/a&gt; and it's available on macOS. It routes all your traffic through any AWS region using CloudShell as an exit node. The total cost is $0 within the free tier's 100 GB/month outbound.&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%2Fkm2ewb6829btg07zbsp4.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%2Fkm2ewb6829btg07zbsp4.png" alt=" " width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The CloudShell problem&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;CloudShell gives you a free Linux shell in any AWS region with outbound internet access. It can run OpenVPN. But it lives behind NAT with no inbound ports. You can't SSH into it, you can't expose a port, and you can't attach an Elastic IP. You can't run SSM &lt;code&gt;StartPortForwardingSession&lt;/code&gt; either: CloudShell isn't a registered SSM managed instance. It only supports interactive shell sessions, not SSM documents like port forwarding.&lt;/p&gt;

&lt;p&gt;So how do you establish a tunnel to something you can't reach?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The NAT hole punching solution&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The approach borrows a technique from peer-to-peer networking. Both sides (your laptop and the CloudShell environment) discover their public IP and port using STUN, then simultaneously send UDP packets to each other. The NAT routers on both sides see outbound traffic and open pinholes. Once both sides have punched through, you have a bidirectional UDP path with no port forwarding required.&lt;/p&gt;

&lt;p&gt;Inspired by &lt;a href="https://github.com/dan-v/cloudshell-store" rel="noopener noreferrer"&gt;Dan V.'s cloudshell-store&lt;/a&gt; project, I realized I could bypass SSM entirely and establish a &lt;em&gt;direct&lt;/em&gt; UDP connection between the laptop and CloudShell using NAT hole punching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 1: Discovering your public face with STUN&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Neither side knows its own public IP address and port. Your laptop knows it's &lt;code&gt;192.168.1.42:12345&lt;/code&gt;, but the outside world sees something like &lt;code&gt;85.123.45.67:54321&lt;/code&gt;, whatever your router's NAT assigned. You can't figure this out by looking at your own network interfaces.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc8489" rel="noopener noreferrer"&gt;STUN&lt;/a&gt; (Session Traversal Utilities for NAT) solves this. It's a simple protocol where you send a UDP packet to a public STUN server and it replies telling you the source IP and port it observed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Your laptop (192.168.1.42:12345)
       │
       │  UDP packet ──────────►  stun.l.google.com:19302
       │                                │
       │                                │ "I see you as 85.123.45.67:54321"
       │                                │
       │  ◄── STUN Binding Response ────┘
       │
  Now you know: your public endpoint is 85.123.45.67:54321
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Google runs free public STUN servers and the whole exchange is a single UDP round-trip. What is important: &lt;strong&gt;the UDP socket you used to contact the STUN server now has an open NAT mapping.&lt;/strong&gt; Your router remembers "packets from &lt;code&gt;192.168.1.42:12345&lt;/code&gt; go out as &lt;code&gt;85.123.45.67:54321&lt;/code&gt;" and that mapping is what we'll exploit next.&lt;/p&gt;

&lt;p&gt;Both sides do this independently: the laptop discovers its public endpoint, and the agent running inside CloudShell discovers its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 2: Exchanging endpoints through the SSM shell&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The laptop knows its own public endpoint and the agent knows its own. But they need to know &lt;em&gt;each other's&lt;/em&gt;, so we use the existing SSM shell session as the signaling channel.&lt;/p&gt;

&lt;p&gt;The laptop already has a terminal session open to CloudShell (that's how we uploaded and started the agent). When the agent starts, it prints its public endpoint to stdout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;AGENT_READY&lt;/span&gt;:&lt;span class="m"&gt;34&lt;/span&gt;.&lt;span class="m"&gt;245&lt;/span&gt;.&lt;span class="m"&gt;12&lt;/span&gt;.&lt;span class="m"&gt;89&lt;/span&gt;:&lt;span class="m"&gt;4433&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The laptop reads this from the SSM session output. The laptop's public endpoint was passed as a command-line argument when starting the agent. Both sides now know where to punch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 3: The hole punch, simultaneous UDP packets&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;NAT mappings are &lt;em&gt;bidirectional once established&lt;/em&gt;. When your laptop sends a UDP packet to &lt;code&gt;34.245.12.89:4433&lt;/code&gt;, your router creates a mapping that says "if a packet comes back from &lt;code&gt;34.245.12.89:4433&lt;/code&gt; to my port &lt;code&gt;54321&lt;/code&gt;, forward it to &lt;code&gt;192.168.1.42:12345&lt;/code&gt;". The same thing happens on CloudShell's side.&lt;/p&gt;

&lt;p&gt;The problem is timing. If the laptop sends first, the packet arrives at CloudShell's NAT &lt;em&gt;before&lt;/em&gt; CloudShell has sent anything, so CloudShell's NAT drops it (no mapping exists yet). The solution: &lt;strong&gt;both sides send packets simultaneously&lt;/strong&gt;. Both NATs create their mappings at roughly the same time, and within a few hundred milliseconds, packets start flowing in both directions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ┌──────────┐                                      ┌──────────┐
  │  Laptop  │                                      │CloudShell│
  │  NAT     │                                      │  NAT     │
  └────┬─────┘                                      └────┬─────┘
       │                                                 │
       │──── UDP "PUNCH" ───────────────────────────────►│ (dropped initially)
       │                                                 │
       │ (dropped initially) ◄──────────── UDP "PUNCH" ──│
       │                                                 │
       │──── UDP "PUNCH" ───────────────────────────────►│ ✓ NAT mapping exists!
       │                                                 │
       │ ✓ NAT mapping exists! ◄────────── UDP "PUNCH" ──│
       │                                                 │
       │◄═══════════════ UDP hole is open ══════════════►│
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In practice, both sides send a burst of &lt;code&gt;PUNCH&lt;/code&gt; packets for a few seconds. After a few hundred milliseconds, both NATs have seen outbound traffic and created their mappings. The hole is punched.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 4: Local UDP relay&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The OpenVPN server starts on port 1194 inside CloudShell. A local UDP relay binds &lt;code&gt;127.0.0.1:1194&lt;/code&gt; on your laptop and bridges packets to the punched hole. On the CloudShell side, an identical relay bridges between the external socket and the local port 1194.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The OpenVPN choice&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I considered several options for the VPN tunnel, including macOS native APIs and WireGuard, but both require either a signed Network Extension or root privileges to create tun interfaces and modify routes. I chose OpenVPN because the free &lt;a href="https://openvpn.net/client/" rel="noopener noreferrer"&gt;OpenVPN Connect&lt;/a&gt; client handles all privileged operations internally: it already has the required entitlements to create the tunnel interface and manage routes, so my tool doesn't need root. It just generates a &lt;code&gt;.ovpn&lt;/code&gt; profile and imports it.&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%2Fh42kxkewf6vjlsghuw25.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%2Fh42kxkewf6vjlsghuw25.png" alt=" " width="800" height="555"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quick start&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The easiest way to run is with the launcher script. It checks prerequisites, manages a virtualenv, and starts the VPN:&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_PROFILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;my-profile
./run.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Et voilà! Your traffic is now routed through the AWS region of your choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Things to know&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;NAT compatibility&lt;/strong&gt; - UDP hole punching works with most residential and cloud NATs. It fails with symmetric NATs, which are common in strict corporate firewalls. If your office blocks it, try from home or a mobile hotspot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudShell quota&lt;/strong&gt; - 200 hours per region per month per account, enough for occasional use. You can request an increase through Service Quotas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session timeout&lt;/strong&gt; - CloudShell auto-terminates after 12 hours of continuous use. The tool sends heartbeats every 5 minutes to prevent idle sleep (20 min timeout), but the 12-hour hard limit still applies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data transfer cost&lt;/strong&gt; - Outbound bytes are billed at standard EC2 rates (~$0.09/GB after the first 100 GB/month free tier).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;macOS only&lt;/strong&gt; - For now.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;If you need a real VPN, pay for a real VPN.&lt;/strong&gt; If you need a free, disposable, region-selectable tunnel for 30 minutes of testing or to bypass a broken CDN geo-block while travelling, this is genuinely useful.&lt;/p&gt;

&lt;p&gt;The source is at &lt;a href="https://github.com/guyon-it-consulting/cloudshell-vpn" rel="noopener noreferrer"&gt;github.com/guyon-it-consulting/cloudshell-vpn&lt;/a&gt;. Give it a try and let me know what you think.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Credits&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Inspired by Dan V.'s &lt;a href="https://github.com/dan-v/cloudshell-store" rel="noopener noreferrer"&gt;cloudshell-store&lt;/a&gt; project that demonstrated the CloudShell API use&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/bdauzats" rel="noopener noreferrer"&gt;Brice Dauzats&lt;/a&gt;: Thanks for the reviews and improvements&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://kiro.dev" rel="noopener noreferrer"&gt;Kiro CLI&lt;/a&gt; and &lt;a href="https://github.com/kirocrew" rel="noopener noreferrer"&gt;Kiro Crew&lt;/a&gt;: Assisted me in research and validating hypotheses throughout the project 👻👻👻&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;— Jerome&lt;/p&gt;

</description>
      <category>networking</category>
      <category>aws</category>
      <category>cloudshell</category>
      <category>vpn</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>Engineering a Multi-Agent AI Platform — Part 1: Adaptive Depth</title>
      <dc:creator>MUHAMMAD ABIODUN SULAIMAN</dc:creator>
      <pubDate>Thu, 13 Aug 2026 19:32:27 +0000</pubDate>
      <link>https://dev.to/aws-builders/engineering-a-multi-agent-ai-platform-part-1-adaptive-depth-pll</link>
      <guid>https://dev.to/aws-builders/engineering-a-multi-agent-ai-platform-part-1-adaptive-depth-pll</guid>
      <description>&lt;p&gt;&lt;em&gt;This is Part 1 of a series on engineering AI systems that learn in production. This part covers the classification and strategy dispatch layer.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem nobody talks about
&lt;/h2&gt;

&lt;p&gt;Here's a question that keeps coming up in production AI systems: should your model think before it answers?&lt;/p&gt;

&lt;p&gt;Not philosophically. Mechanically. When a user asks "What's my account balance?", firing up a chain-of-thought reasoning engine with 16,000 thinking tokens is like hiring a PhD mathematician to count change. You burn compute, you add latency, and the answer isn't any better for it. But when someone asks "Compare the ROI of three deployment architectures under peak load, factoring in failure modes and regional latency differences," a zero-reasoning direct call produces garbage.&lt;/p&gt;

&lt;p&gt;Most platforms pick one approach and apply it everywhere. The expensive ones over-reason on simple queries, bleeding money on thinking tokens that produce nothing useful. The cheap ones under-reason on hard problems, producing responses that sound confident but miss the point entirely.&lt;/p&gt;

&lt;p&gt;Consider a platform where dozens of autonomous agents serve thousands of tenants across different industries. A scheduling agent handles "move my 3pm to Thursday" alongside an analytics agent wrestling with "why did churn spike in Q3 among enterprise accounts." These requests hit the same infrastructure, but they need radically different cognitive investment.&lt;/p&gt;

&lt;p&gt;The system we're building doesn't pick a single depth. It scores each request and routes it to the appropriate reasoning tier. And then, as we'll see in Parts 2 and 3, it learns from outcomes and human feedback to get better at that scoring over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three tiers of thinking
&lt;/h2&gt;

&lt;p&gt;Think of reasoning depth as a spectrum with three practical zones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 1: No intermediate reasoning (NONE)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The model receives the prompt and generates a response directly. No thinking tokens, no structured intermediate steps. Input goes in, output comes out. This is your workhorse for factual lookups, simple status checks, greetings, and FAQ-style questions. Latency sits around 200ms. Cost is minimal.&lt;/p&gt;

&lt;p&gt;When does this fail? When the question requires the model to hold multiple constraints in working memory simultaneously, or when the answer depends on comparing several options against each other. The model will produce something plausible-sounding, but it hasn't actually reasoned through the tradeoffs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 2: Lightweight structured reasoning (DRAFT)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here, the system injects a structured instruction into the prompt: "Before answering, write your reasoning inside &lt;code&gt;&amp;lt;draft&amp;gt;&lt;/code&gt; tags, limited to 512 tokens. Then provide your final answer inside &lt;code&gt;&amp;lt;answer&amp;gt;&lt;/code&gt; tags."&lt;/p&gt;

&lt;p&gt;The model gets a small scratchpad. It can organize its thoughts, check a few constraints, maybe outline a comparison. But the budget is tight. 512 tokens of reasoning is enough to decompose a moderately complex question into sub-parts and address each one, but not enough to explore rabbit holes or consider five alternative framings.&lt;/p&gt;

&lt;p&gt;The system parses the response, extracts what's between the &lt;code&gt;&amp;lt;answer&amp;gt;&lt;/code&gt; tags, discards the draft. The user never sees the intermediate reasoning unless the operator wants to inspect it for debugging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 3: Full chain-of-thought (FULL_COT)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This tier hands the problem to a model with native thinking-token support. Models like o3 or DeepSeek-R1 have a separate "thinking" channel where they can reason at length before producing a visible response. The system sets a &lt;code&gt;reasoning_effort&lt;/code&gt; parameter and allocates a thinking budget (more on budgets in a moment).&lt;/p&gt;

&lt;p&gt;The model can now spend thousands of tokens working through a problem. It can consider alternatives, backtrack when it hits a dead end, verify its own logic. For genuinely hard analytical questions, this produces qualitatively different answers than the other two tiers.&lt;/p&gt;

&lt;p&gt;The tradeoff is obvious: latency climbs to 2-5 seconds and token costs multiply. You don't want this running on "What time do you close?"&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoring complexity
&lt;/h2&gt;

&lt;p&gt;So how does the system decide which tier to use? It runs the incoming request through a multi-signal classifier that produces a single score between 0 and 1.&lt;/p&gt;

&lt;p&gt;Five signals feed the score:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Task type (weight: 0.30).&lt;/strong&gt; Is this a factual lookup? A comparison? An analysis? A debugging request? A creative task? The classifier maintains a taxonomy of task types with associated difficulty priors. A status check scores low. A multi-criteria comparison scores high.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ambiguity (weight: 0.20).&lt;/strong&gt; How many ways could this request be interpreted? "Tell me about performance" is ambiguous (performance of what? the system? the team? a specific metric?). "What was the p95 latency for the /checkout endpoint last Tuesday" is not. Ambiguity markers include vague pronouns, missing context, and underspecified scope.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Domain vocabulary density (weight: 0.20).&lt;/strong&gt; Requests loaded with specialized terminology signal that the answer needs domain expertise and careful reasoning. A message full of financial modeling terms or medical terminology likely needs deeper thinking than a conversational greeting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lexical complexity (weight: 0.15).&lt;/strong&gt; Sentence length, subordinate clause depth, vocabulary sophistication. A proxy for how much cognitive load the request itself carries. Not a perfect signal (a short request can demand complex reasoning), but a useful one in combination with the others.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context dependency (weight: 0.15).&lt;/strong&gt; How much does the correct answer depend on prior conversation history, tenant-specific data, or cross-referencing multiple information sources? A standalone question scores lower than one that requires synthesizing three previous messages and two documents.&lt;/p&gt;

&lt;p&gt;The composite score:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;C(x) = 0.30 · S_task(x) + 0.20 · S_amb(x) + 0.20 · S_dom(x) + 0.15 · S_lex(x) + 0.15 · S_ctx(x)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each signal function &lt;code&gt;S(x)&lt;/code&gt; outputs a value in [0, 1]. The weighted sum produces the final complexity score &lt;code&gt;C(x)&lt;/code&gt;, also in [0, 1].&lt;/p&gt;

&lt;p&gt;The routing thresholds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;C(x) &amp;lt; 0.30&lt;/code&gt; → NONE (direct call, no reasoning)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;0.30 ≤ C(x) &amp;lt; 0.65&lt;/code&gt; → DRAFT (lightweight scratchpad, 512-token cap)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;C(x) ≥ 0.65&lt;/code&gt; → FULL_COT (native thinking tokens with budget)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A concrete example makes this tangible. "What is my account balance?" scores roughly: task_type = 0.1 (factual lookup), ambiguity = 0.1 (clear intent), domain = 0.05 (no specialized vocabulary), lexical = 0.1 (short, simple), context = 0.2 (needs account data but straightforward). Composite: 0.30(0.1) + 0.20(0.1) + 0.20(0.05) + 0.15(0.1) + 0.15(0.2) = 0.03 + 0.02 + 0.01 + 0.015 + 0.03 = 0.105. NONE tier. Just answer it.&lt;/p&gt;

&lt;p&gt;Now try "Compare the ROI of three deployment architectures considering latency, cost, and failure modes under peak load." Task_type = 0.9 (multi-criteria analysis), ambiguity = 0.4 (what counts as "ROI" needs interpretation), domain = 0.8 (infrastructure terminology), lexical = 0.7 (complex sentence, multiple constraints), context = 0.6 (needs architecture details from prior context). Composite: 0.30(0.9) + 0.20(0.4) + 0.20(0.8) + 0.15(0.7) + 0.15(0.6) = 0.27 + 0.08 + 0.16 + 0.105 + 0.09 = 0.705. FULL_COT. This one needs to think.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters in multi-agent systems
&lt;/h2&gt;

&lt;p&gt;A single-agent chatbot can get away with a fixed reasoning depth. But multi-agent platforms have a topology that makes static approaches break down.&lt;/p&gt;

&lt;p&gt;Picture a supervisor agent that receives every inbound message. It classifies intent and routes to specialist agents: a scheduling agent, a billing agent, a technical support agent, an analytics agent. Each specialist handles a different slice of the problem space.&lt;/p&gt;

&lt;p&gt;Here's what's interesting: the same specialist agent might need different reasoning depths depending on the specific request. The billing agent handles "what's my next payment date?" (NONE) alongside "explain why my invoice increased 40% this month considering the three pricing tiers, the mid-cycle plan change, and the prorated adjustments" (FULL_COT).&lt;/p&gt;

&lt;p&gt;The reasoning strategy is selected per-request, not per-agent. The billing agent doesn't have a fixed reasoning depth. It gets classified fresh on every interaction based on what the user actually asked.&lt;/p&gt;

&lt;p&gt;It gets more layered when you have agent teams. A lead agent might decompose a complex request into subtasks, delegating each to a different specialist. One subtask might be simple data retrieval (NONE), another might require cross-referencing multiple sources (DRAFT), and a third might involve genuine analytical reasoning (FULL_COT). The system scores each subtask independently.&lt;/p&gt;

&lt;p&gt;This per-request granularity is what makes the whole thing tractable economically. If you set every agent to FULL_COT because some of their tasks are hard, you're paying ten times more for the easy ones. If you set them all to NONE because most tasks are easy, the hard ones produce bad answers and you lose user trust. Scoring at the request level means you pay for reasoning exactly where it creates value.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thinking budget
&lt;/h2&gt;

&lt;p&gt;Even within FULL_COT, not every hard problem deserves the maximum thinking allocation. A moderately complex question might need 4,096 thinking tokens to work through. A genuinely difficult analytical task might need 16,384. Allocating 32,000 thinking tokens to a question that only needs 4,000 wastes compute and sometimes degrades quality (the model can over-think, going in circles or second-guessing correct intermediate steps).&lt;/p&gt;

&lt;p&gt;The system maps complexity sub-ranges to budget tiers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Simple (below threshold): 0 tokens (NONE/DRAFT handle it)&lt;/li&gt;
&lt;li&gt;Moderate (0.65-0.80): 4,096 tokens&lt;/li&gt;
&lt;li&gt;Complex (0.80-1.0): 16,384 tokens&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Budget enforcement works as a state machine. When the model starts thinking, the state is ACTIVE. If the model reaches the budget limit, the state transitions to EXHAUSTED, and the system signals the model to wrap up its reasoning. A grace window of 512 tokens allows the model to reach a natural stopping point rather than being cut off mid-thought. After the grace window, the state transitions to TERMINATED and the model must produce its final answer from whatever reasoning it completed.&lt;/p&gt;

&lt;p&gt;This prevents runaway thinking. Without budget enforcement, a model given native thinking tokens can sometimes spend 20,000+ tokens reasoning in circles on a problem that doesn't benefit from it. The budget creates a ceiling without preventing deep reasoning when it's needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diagram
&lt;/h2&gt;

&lt;p&gt;Here's the full request lifecycle through strategy selection:&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%2F1zyez9mbve302dygxw3n.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%2F1zyez9mbve302dygxw3n.png" alt=" " width="800" height="782"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A request enters the complexity classifier, which evaluates five signal dimensions and produces a weighted score. The score crosses one of two thresholds (0.30 and 0.65), routing the request into one of three strategy lanes. Each lane uses a different mechanical approach to produce the final response: a simple pass-through call, a structured draft envelope with tag parsing, or a full thinking-token API with budget enforcement. All three converge on a response delivered to the user.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's missing
&lt;/h2&gt;

&lt;p&gt;The classifier works. It routes most requests to reasonable tiers. But it's a heuristic, and heuristics don't learn.&lt;/p&gt;

&lt;p&gt;If the system consistently under-reasons on a particular type of request for a specific agent, the classifier keeps making the same mistake. If tenant patterns shift (maybe a business changes industries and their support queries become more technical), the static weights don't adapt.&lt;/p&gt;

&lt;p&gt;What we actually want is a system that observes outcomes. Did the DRAFT strategy produce a good result for this context, or did it fall short? Was the FULL_COT response worth the extra latency and cost, or would DRAFT have been equally good?&lt;/p&gt;

&lt;p&gt;That's the reinforcement learning router, and it's the subject of Part 2. The core idea: maintain a probabilistic belief about how well each (model, strategy) pair performs in each context, sample from that belief to select an action, observe the outcome, and update the belief. A contextual bandit that learns the terrain.&lt;/p&gt;

&lt;p&gt;The classifier gives us a reasonable starting position. The bandit learns to do better.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Next in the series: Part 2 explores how Thompson Sampling learns optimal model+strategy routing from production outcomes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You can follow me on &lt;a href="https://linkedin.com/in/muhammad-abiodun-sulaiman" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; and &lt;a href="https://X.com/@Prince_Analyst" rel="noopener noreferrer"&gt;Twitter&lt;/a&gt; for more updates.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>systemdesign</category>
      <category>multiagentsystem</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Running Gemma 4 on EC2 G5g: Graviton2 AMD with NVIDIA GPU</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Thu, 13 Aug 2026 18:45:41 +0000</pubDate>
      <link>https://dev.to/aws-builders/running-gemma-4-on-ec2-g5g-graviton2-amd-with-nvidia-gpu-13j</link>
      <guid>https://dev.to/aws-builders/running-gemma-4-on-ec2-g5g-graviton2-amd-with-nvidia-gpu-13j</guid>
      <description>&lt;p&gt;&lt;em&gt;A field report on serving Google's Gemma 4 E2B on AWS EC2 **G5g&lt;/em&gt;* — a Graviton2 (aarch64)&lt;br&gt;
host with an NVIDIA &lt;strong&gt;T4G&lt;/strong&gt; (Turing, SM 7.5) GPU. Three obstacles: an &lt;strong&gt;arch list&lt;/strong&gt; nobody&lt;br&gt;
publishes for this combination, a &lt;strong&gt;version floor&lt;/strong&gt; that only the newest vLLM clears, and&lt;br&gt;
&lt;strong&gt;64 KiB of shared memory&lt;/strong&gt; that stops the model dead. Plus the seven things I documented&lt;br&gt;
wrong before I had a box.*&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Model&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;google/gemma-4-E2B-it&lt;/code&gt; (reference bf16 release)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hardware&lt;/td&gt;
&lt;td&gt;AWS EC2 &lt;code&gt;g5g.4xlarge&lt;/code&gt; — Graviton2 + 1x NVIDIA T4G, compute capability &lt;strong&gt;7.5&lt;/strong&gt;, 15,360 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Base image&lt;/td&gt;
&lt;td&gt;Deep Learning ARM64 AMI OSS Nvidia Driver GPU PyTorch 2.12 (Ubuntu 24.04)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Software&lt;/td&gt;
&lt;td&gt;torch 2.12.0+cu132 · CUDA 13.2 · vLLM v0.27.2rc0 built from source for &lt;code&gt;sm_75&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Result&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;43.1 tok/s&lt;/strong&gt; single-stream greedy, 329,579-token KV cache — after one patch to vLLM&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;p&gt;G5g is the only instance AWS has ever shipped that puts an NVIDIA GPU behind a Graviton&lt;br&gt;
host. It launched in 2020, it never got a successor, and Graviton is now on its fifth&lt;br&gt;
generation without one.&lt;/p&gt;

&lt;p&gt;That matters more than it sounds. The Arm-plus-CUDA world moved on to NVIDIA's own Arm CPU&lt;br&gt;
— Grace, paired with SM 9.0 and 10.0 parts. Turing stayed well supported, on x86. G5g is&lt;br&gt;
the only hardware that is aarch64 &lt;em&gt;and&lt;/em&gt; compute capability 7.5, and almost nobody publishes&lt;br&gt;
a build for that combination.&lt;/p&gt;

&lt;p&gt;I put a rig on one anyway. &lt;strong&gt;The packaging problem was the quick part.&lt;/strong&gt; Everything after it&lt;br&gt;
— a compiler that was not there, a version floor I did not expect, and 32 KiB of shared&lt;br&gt;
memory — took far longer, because none of it fails where you are looking.&lt;/p&gt;
&lt;h2&gt;
  
  
  No published build covers aarch64 and SM 7.5 together
&lt;/h2&gt;

&lt;p&gt;Start with the obvious candidate. &lt;code&gt;vllm/vllm-openai:v0.27.1&lt;/code&gt; publishes both platforms under&lt;br&gt;
one tag, and you can read the arch lists straight out of the image config without pulling a&lt;br&gt;
layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker buildx imagetools inspect vllm/vllm-openai:v0.27.1 &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{json .Image}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;linux/amd64   7.5 8.0 8.6 8.9 9.0 10.0 12.0
linux/arm64       8.0 8.7 8.9 9.0 10.0 11.0 12.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one architecture this hardware needs is the only entry the two images disagree on. The&lt;br&gt;
arm64 list is Ampere and up, because that is what ships as an Arm-plus-NVIDIA system: A100,&lt;br&gt;
Jetson Orin, GH200, Blackwell. Turing is not on that list and never will be.&lt;/p&gt;

&lt;p&gt;Normally a missing target degrades to JIT from embedded PTX. Not here. The Dockerfile says&lt;br&gt;
so, with a comment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Do not add +PTX here: vLLM filters torch's top-level PTX flag when it&lt;/span&gt;
&lt;span class="c"&gt;# converts global gencode flags into per-kernel arch lists.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So it does not run slowly. It fails outright, with &lt;code&gt;no kernel image is available for&lt;br&gt;
execution on the device&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The rest of the ecosystem splits the same way. Check before you plan anything:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Artifact&lt;/th&gt;
&lt;th&gt;7.5 on arm64&lt;/th&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;vllm/vllm-openai&lt;/code&gt; arm64&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;Current. Never had it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;nvcr.io/nvidia/pytorch&lt;/code&gt; arm64&lt;/td&gt;
&lt;td&gt;through 24.10&lt;/td&gt;
&lt;td&gt;Dropped by 24.12.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;drikster80/vllm-aarch64&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;Abandoned Sept 2024. vLLM 0.6.1, far too old for Gemma 4.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PyPI torch aarch64&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;Built for 9.0 / 10.0 / 12.0.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AWS ARM64 GPU DLAMI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Maintained. PyTorch 2.2 through 2.12.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  AWS ships the one PyTorch that still has Turing
&lt;/h2&gt;

&lt;p&gt;This is the finding that saves the whole exercise, and I nearly wrote it off. I had assumed&lt;br&gt;
PyTorch's aarch64 CUDA wheels lacked &lt;code&gt;sm_75&lt;/code&gt; and that a from-source PyTorch build was&lt;br&gt;
coming. That is true of the PyPI wheels. It is not true of AWS.&lt;/p&gt;

&lt;p&gt;Read on two different DLAMIs, on the box:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;torch&lt;/span&gt; &lt;span class="mf"&gt;2.7&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;cu128&lt;/span&gt;    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sm_75&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;sm_90&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;sm_100&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;sm_120&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;torch&lt;/span&gt; &lt;span class="mf"&gt;2.12&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;cu132&lt;/span&gt;   &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sm_75&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;sm_80&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;sm_90&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;sm_100&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;sm_110&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;sm_120&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;AWS sells G5g, so AWS keeps Turing in the build — right through PyTorch 2.12 on CUDA 13.2,&lt;br&gt;
an image cut three months ago. &lt;strong&gt;PyTorch never needs building.&lt;/strong&gt; Only vLLM's own kernels do,&lt;br&gt;
and CMake takes the arch list without argument:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cmake"&gt;&lt;code&gt;-- CUDA target architectures: 7.5
CMake Warning: Pytorch version 2.11.0 expected for CUDA build, saw 2.12.0 instead.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That warning is worth reading twice, and I come back to it below.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PyTorch DLAMI has no compiler
&lt;/h2&gt;

&lt;p&gt;Two things the DLAMI does not give you, neither of them documented anywhere I could find.&lt;/p&gt;

&lt;p&gt;There is no &lt;code&gt;nvcc&lt;/code&gt;. The image ships the driver and a torch built against CUDA, not the&lt;br&gt;
toolkit. You need the keyring and &lt;code&gt;cuda-toolkit-13-2&lt;/code&gt; from NVIDIA's &lt;strong&gt;sbsa&lt;/strong&gt; repo — not the&lt;br&gt;
x86 one, which is an easy reflex to get wrong on an Arm box.&lt;/p&gt;

&lt;p&gt;And vLLM now wants Rust. Its &lt;code&gt;vllm-rs&lt;/code&gt; frontend needs &lt;code&gt;setuptools_rust&lt;/code&gt; plus a toolchain,&lt;br&gt;
and the failure is a bare &lt;code&gt;ModuleNotFoundError: No module named 'setuptools_rust'&lt;/code&gt; thrown&lt;br&gt;
from metadata generation, several minutes in.&lt;/p&gt;
&lt;h2&gt;
  
  
  The newest vLLM was the only one that worked
&lt;/h2&gt;

&lt;p&gt;No vLLM tag pins torch 2.12. They go 2.11, then jump to 2.13. I reasoned that building older&lt;br&gt;
code against a newer runtime was the safer direction, took v0.26.0, and spent an hour being&lt;br&gt;
wrong about it.&lt;/p&gt;

&lt;p&gt;It builds fine. It then dies on model load:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;transformers.integrations.heterogeneity.configuration_utils.AmbiguousGlobalPerLayerAttributeError:
'head_dim' is a per-layer attribute and may vary across layers.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gemma 4's &lt;code&gt;head_dim&lt;/code&gt; is not one number, and current &lt;code&gt;transformers&lt;/code&gt; refuses to hand out a&lt;br&gt;
global value for it. vLLM's config converter was still doing a flat&lt;br&gt;
&lt;code&gt;getattr(config, "head_dim", 0)&lt;/code&gt;. The &lt;code&gt;per_layer_config&lt;/code&gt; handling that copes with it landed&lt;br&gt;
in &lt;strong&gt;v0.27.2rc0&lt;/strong&gt; — not v0.27.1, which I also checked. The newest tag was the only one that&lt;br&gt;
worked.&lt;/p&gt;

&lt;p&gt;If you take one process lesson from this: reach for the latest release first, and make the&lt;br&gt;
constraint say out loud what stopped you when you fall back.&lt;/p&gt;
&lt;h2&gt;
  
  
  Gemma 4's attention heads are not one size
&lt;/h2&gt;

&lt;p&gt;With the build working the server still would not start, and this failure has nothing to do&lt;br&gt;
with Arm or packaging. It is this model against this chip.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Gemma4 model has heterogeneous head dimensions
{'sliding_attention': 256, 'full_attention': 512}.
FA4 not available, forcing TRITON_ATTN backend.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that as a chain, because every link is load-bearing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Gemma 4's sliding layers are 256 wide. Its global layers are &lt;strong&gt;512&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Only FA4 or Triton support heterogeneous head dims at all.&lt;/li&gt;
&lt;li&gt;FA4 is not available, so vLLM forces &lt;code&gt;TRITON_ATTN&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;That choice is not yours to make. &lt;code&gt;VLLM_ATTENTION_BACKEND&lt;/code&gt; is not a recognised variable
in v0.27 — it logs &lt;code&gt;Unknown vLLM environment variable detected&lt;/code&gt; and carries on. I set it
twice before I read the warning.&lt;/li&gt;
&lt;li&gt;Triton's unified attention kernel at &lt;code&gt;head_size=512&lt;/code&gt; wants about 96 KiB of shared memory
per block.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  64 KiB is the whole problem
&lt;/h2&gt;

&lt;p&gt;Turing's shared memory is two numbers, and both are real. The &lt;strong&gt;default&lt;/strong&gt; static limit per block&lt;br&gt;
is 48 KiB — that is what &lt;code&gt;torch.cuda.get_device_properties().shared_memory_per_block&lt;/code&gt; reports,&lt;br&gt;
49,152 bytes. A kernel that needs more has to opt in through the dynamic shared-memory&lt;br&gt;
attribute, and even then it tops out at &lt;strong&gt;64 KiB&lt;/strong&gt;. Ampere and later have 164 KiB and up.&lt;/p&gt;

&lt;p&gt;Triton opts in, so it is measuring against the 64 KiB ceiling. It still does not fit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;triton.runtime.errors.OutOfResources: out of resource: shared memory,
Required: 98304, Hardware limit: 65536
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Refused outright. Not slow, not degraded — the kernel will not launch, and it takes the&lt;br&gt;
engine down during CUDA graph capture, which is late enough that you have already watched&lt;br&gt;
the weights load and the KV cache get sized.&lt;/p&gt;

&lt;p&gt;The fix is small. Shrink the KV tile until the query block and the K/V tiles fit inside the&lt;br&gt;
budget, and drop the software pipeline to one stage. Gate it on pre-Ampere so it is a no-op&lt;br&gt;
on every other card:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current_platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_device_capability&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;_smem_budget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt;
    &lt;span class="n"&gt;_esz&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;element_size&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;_fits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BLOCK_M&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;head_size&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;_esz&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;_smem_budget&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;TILE_SIZE_PREFILL&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;_fits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TILE_SIZE_PREFILL&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;TILE_SIZE_PREFILL&lt;/span&gt; &lt;span class="o"&gt;//=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;TILE_SIZE_DECODE&lt;/span&gt;  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;_fits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TILE_SIZE_DECODE&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;  &lt;span class="n"&gt;TILE_SIZE_DECODE&lt;/span&gt;  &lt;span class="o"&gt;//=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="n"&gt;launch_num_stages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that in &lt;code&gt;vllm/v1/attention/ops/triton_unified_attention.py&lt;/code&gt;, graphs capture, the engine&lt;br&gt;
comes up in 76 seconds, and the model serves. &lt;strong&gt;This is not upstream.&lt;/strong&gt; It lives on my&lt;br&gt;
instance and has to be reapplied on any vLLM upgrade, which makes it the obvious thing to&lt;br&gt;
send back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Most of the build is kernels that can never load
&lt;/h2&gt;

&lt;p&gt;67 minutes on a &lt;code&gt;g5g.4xlarge&lt;/code&gt; at &lt;code&gt;MAX_JOBS=12&lt;/code&gt;, and the majority of it is FlashAttention.&lt;br&gt;
vLLM compiles FA2 and FA3 &lt;strong&gt;regardless of &lt;code&gt;TORCH_CUDA_ARCH_LIST&lt;/code&gt;&lt;/strong&gt; — I watched it grind&lt;br&gt;
through hundreds of &lt;code&gt;sm90&lt;/code&gt; Hopper instantiations on a build targeting 7.5 only. FA2 needs&lt;br&gt;
sm80, FA3 needs sm90. Neither can ever load on this card.&lt;/p&gt;

&lt;p&gt;Constraining &lt;code&gt;VLLM_FA_CMAKE_GPU_ARCHES&lt;/code&gt; should cut that dramatically. I did not try it,&lt;br&gt;
because by the time I understood what I was looking at the build was 45 minutes in and&lt;br&gt;
interrupting it would have cost more than finishing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got wrong before I had hardware
&lt;/h2&gt;

&lt;p&gt;I wrote the rig's documentation before provisioning anything. Seven claims in it were wrong,&lt;br&gt;
and every correction came off the machine rather than out of an argument. This is the part I&lt;br&gt;
would keep if I kept nothing else.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What I wrote&lt;/th&gt;
&lt;th&gt;What the box said&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PyTorch aarch64 lacks &lt;code&gt;sm_75&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;AWS DLAMI has it, on both versions I checked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bfloat16 is a hard failure here&lt;/td&gt;
&lt;td&gt;Torch upconverts; vLLM logs &lt;code&gt;Casting torch.bfloat16 to torch.float16&lt;/code&gt; and proceeds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The backend is XFORMERS&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;TRITON_ATTN&lt;/code&gt;, forced, not selectable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;VLLM_ATTENTION_BACKEND&lt;/code&gt; picks it&lt;/td&gt;
&lt;td&gt;Not a recognised variable. I had shipped dead config.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;w4a16 needs sm80+ Marlin&lt;/td&gt;
&lt;td&gt;The build compiled &lt;code&gt;sm75_kernel_float16_u4b8_float16.cu.o&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The GPU has 16 GB&lt;/td&gt;
&lt;td&gt;15,360 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;/v1/completions&lt;/code&gt; returns an empty body&lt;/td&gt;
&lt;td&gt;It returns &lt;code&gt;': ok: ok: ok: ok'&lt;/code&gt; — garbage, not silence&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last one has teeth. If you health-check by testing for an empty response, this endpoint&lt;br&gt;
passes while producing nonsense. Use &lt;code&gt;/v1/chat/completions&lt;/code&gt; and read the text.&lt;/p&gt;

&lt;p&gt;One claim is still standing only because I never tested it: whether &lt;code&gt;g5g.xlarge&lt;/code&gt;'s 8 GiB of&lt;br&gt;
host RAM can stage 9.5 GiB of weights. Safetensors loading is mmap-backed, so I suspect it&lt;br&gt;
can. It is labelled untested rather than stated as fact, which is where it should have been&lt;br&gt;
all along.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does once it runs
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Site&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Reliability&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Engineering&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(SRE)&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;discipline&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;that&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;applies&lt;/span&gt;
          &lt;span class="s"&gt;software&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;engineering&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;principles&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;infrastructure&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;operations&lt;/span&gt;
          &lt;span class="s"&gt;problems&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;create&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;highly&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;reliable,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;scalable,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;efficient&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;systems.'&lt;/span&gt;
&lt;span class="na"&gt;finish_reason: stop      usage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;19 prompt / 32 completion / 51 total&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measure&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Throughput, single stream greedy&lt;/td&gt;
&lt;td&gt;42.9 tok/s @ 64, 43.1 @ 256&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;KV cache&lt;/td&gt;
&lt;td&gt;2.95 GiB, 329,579 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrency at 16k context&lt;/td&gt;
&lt;td&gt;20.12x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU memory while serving&lt;/td&gt;
&lt;td&gt;13,501 / 15,360 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Engine init&lt;/td&gt;
&lt;td&gt;76.4 s, graph capture 17 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory bandwidth, measured&lt;/td&gt;
&lt;td&gt;277.0 GB/s read · 234.3 GB/s copy (320.1 theoretical)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Before reading too much into 43 tok/s, note what the memory does. The T4G has &lt;strong&gt;GDDR6, not&lt;br&gt;
HBM&lt;/strong&gt; — 256-bit bus at 5,001 MHz, so 320 GB/s theoretical. I measured &lt;strong&gt;277 GB/s&lt;/strong&gt; on a&lt;br&gt;
streaming read (87% of peak) and 234 GB/s on a read-modify-write. Decode is bandwidth-bound,&lt;br&gt;
so 277 is the real ceiling. For scale, a TPU v5e is about 859 GB/s normalized and a v6e about&lt;br&gt;
1,638 — this part has roughly a third of one and a sixth of the other. It is a bandwidth-limited&lt;br&gt;
card behaving like a bandwidth-limited card.&lt;/p&gt;

&lt;p&gt;Single run, single stream, no repeats and no variance figure. One sample per cell, and taken&lt;br&gt;
with the clamped tiles, so it is a floor rather than a characterisation. My Inferentia port&lt;br&gt;
measured about 44 tok/s for E2B on one core, which is the same neighbourhood — but that is a&lt;br&gt;
different harness on different silicon and I would not put the two in one table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Troubleshooting quick reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no kernel image is available&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Stock arm64 image. No 7.5, no PTX. Build from source.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OutOfResources: shared memory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Turing's 64 KiB against a 512-wide head. Clamp the tiles.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AmbiguousGlobalPerLayerAttributeError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;vLLM older than v0.27.2rc0.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;No module named 'setuptools_rust'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Missing Rust toolchain for &lt;code&gt;vllm-rs&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nvcc: not found&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PyTorch DLAMI has no toolkit. Install &lt;code&gt;cuda-toolkit-13-2&lt;/code&gt; (sbsa).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Unknown vLLM environment variable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;You set &lt;code&gt;VLLM_ATTENTION_BACKEND&lt;/code&gt;. It does nothing.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Healthy endpoint, nonsense output&lt;/td&gt;
&lt;td&gt;You checked &lt;code&gt;/v1/completions&lt;/code&gt;. Use chat completions.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Take the AWS ARM64 GPU PyTorch DLAMI — it is the only maintained aarch64 stack that still&lt;br&gt;
carries &lt;code&gt;sm_75&lt;/code&gt;. Add &lt;code&gt;cuda-toolkit-13-2&lt;/code&gt; from the sbsa repo and a Rust toolchain, because the&lt;br&gt;
image ships neither. Build vLLM v0.27.2rc0 or newer from source with&lt;br&gt;
&lt;code&gt;TORCH_CUDA_ARCH_LIST=7.5&lt;/code&gt; and &lt;code&gt;use_existing_torch.py&lt;/code&gt;, and patch the Triton attention kernel&lt;br&gt;
to fit Turing's shared memory before you try to start it. Serve with &lt;code&gt;--dtype float16&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;--kv-cache-dtype auto&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Nothing here failed loudly, and nothing failed where I was looking. The packaging gap I built&lt;br&gt;
the rig around was already solved by AWS; the thing that actually stopped me was 32 KiB of&lt;br&gt;
shared memory and a model whose global attention heads are twice as wide as its sliding ones.&lt;br&gt;
Hardware this far off the mainstream will keep producing that shape of surprise — the fix is&lt;br&gt;
not to reason harder about it, but to get to a box sooner and let it tell you.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Measured on EC2 &lt;code&gt;g5g.4xlarge&lt;/code&gt; spot, &lt;code&gt;us-east-1a&lt;/code&gt;. NVIDIA T4G, compute capability 7.5,&lt;br&gt;
15,360 MiB, driver 595.71.05. Deep Learning ARM64 AMI OSS Nvidia Driver GPU PyTorch 2.12&lt;br&gt;
(Ubuntu 24.04). torch 2.12.0+cu132, CUDA 13.2. vLLM 0.27.2rc1.dev0+g7f7a32cfe built from&lt;br&gt;
v0.27.2rc0.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>vllm</category>
      <category>cuda</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>0.3% In, 36% Out: Your Fine-Tuned Model Is Copying Your Prompt Examples</title>
      <dc:creator>Chidozie Uzoegwu</dc:creator>
      <pubDate>Thu, 13 Aug 2026 17:08:00 +0000</pubDate>
      <link>https://dev.to/aws-builders/03-in-36-out-your-fine-tuned-model-is-copying-your-prompt-examples-1f21</link>
      <guid>https://dev.to/aws-builders/03-in-36-out-your-fine-tuned-model-is-copying-your-prompt-examples-1f21</guid>
      <description>&lt;p&gt;I run a fine-tuned Llama 3.3 70B on Amazon Bedrock. It generates short first-person narrative posts: a setup, a few lines of story, and a closing line that lands the point.&lt;/p&gt;

&lt;p&gt;Last week I noticed the closing lines had collapsed into a single template. Not similar in spirit, but literally the same grammatical shape, over and over:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;and that's how [someone] [learns/teaches] [something].&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Roughly a third of everything the model produced ended that way. The obvious diagnosis was overfitting: the training data must be saturated with that pattern, so the model learned it as &lt;em&gt;the&lt;/em&gt; way to end a story. The obvious fix was equally clear. Scrub the training set, retrain, redeploy.&lt;/p&gt;

&lt;p&gt;In my setup that runs to about $30 of training compute and a five-hour training job, plus the evaluation pass afterwards before I'd know whether it had worked. Not ruinous, but not free either, and this would have been the third retrain cycle.&lt;/p&gt;

&lt;p&gt;Before spending it, I did something I should have done first. I counted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step one: count the pattern in your training data
&lt;/h2&gt;

&lt;p&gt;My training file is JSONL, one example per line. The check took about ten seconds:&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;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"that's how"&lt;/span&gt; train_v6_2.jsonl | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="c"&gt;# 5&lt;/span&gt;

&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; train_v6_2.jsonl
&lt;span class="c"&gt;# 1610&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Five occurrences in 1,610 training examples. 0.3%.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Then I counted the same pattern in what the model was actually producing. Every generated post gets persisted, so this was one SQL query over the last 25 rows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;right&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&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="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ending&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;generated_stories&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;
&lt;span class="k"&gt;limit&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Nine of twenty-five. 36%.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Those two numbers are irreconcilable. Training data at 0.3% cannot produce output at 36%. Fine-tuning shifts a model's distribution, but it does not amplify a pattern by a factor of a hundred. Whatever was driving this, it was not the weights.&lt;/p&gt;

&lt;p&gt;Which meant a retrain would have changed nothing, and I'd have concluded my fine-tune was broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step two: read your own prompt
&lt;/h2&gt;

&lt;p&gt;The generation prompt included a line I had written months earlier and never revisited. Structurally it looked like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;THE CLOSER: lead with a thesis line that names what the story proves, e.g. &lt;em&gt;"and that's how [X] teaches [Y] to expect [Z]."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There it is. I had handed the model one complete, well-formed example of exactly the thing I wanted, and it did the most reasonable thing available to it. It copied the example, template and all.&lt;/p&gt;

&lt;p&gt;This is not the model misbehaving. Give a language model a single concrete instance of a pattern and ask it to produce that pattern, and the example becomes the strongest signal in the context window. It will out-compete a 0.3% tendency in the weights every time. I had effectively hardcoded my output and then blamed the training data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step three: fix the injection layer, not the model
&lt;/h2&gt;

&lt;p&gt;The fix cost nothing and shipped in an afternoon.&lt;/p&gt;

&lt;p&gt;Instead of one hardcoded example, I built a small pool of &lt;strong&gt;seven&lt;/strong&gt; closing lines. The key property is that they are &lt;strong&gt;structurally dissimilar&lt;/strong&gt;: a flat verdict, a two-beat reveal, a consequence line, a prescriptive line, an ironic callback. Critically, &lt;strong&gt;none of them uses the shape that was being copied.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My production lines come from a private archive, so here is an illustrative pool in a neutral domain that shows the same variety:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_CLOSER_EXEMPLARS&lt;/span&gt; &lt;span class="o"&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;The process was the problem.&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;Nobody had tested it. Everybody had approved it.&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;We saved an hour that afternoon and spent the next year paying for it.&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;Write the runbook before you need it. You will not be calm enough later.&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;So much for the quick fix.&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;We called it a deadline for four months before anyone admitted it was a guess.&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;What would you have checked first?&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="n"&gt;examples&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&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;- &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_CLOSER_EXEMPLARS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&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;Three are sampled at random per call, plus one explicit instruction: study the function these serve, then write a new one for this story, and do not reuse their wording.&lt;/p&gt;

&lt;p&gt;The reasoning is that &lt;strong&gt;one example teaches a template, while several dissimilar examples teach a function.&lt;/strong&gt; If every example looks different but they all achieve the same effect, the only thing left to imitate is the effect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Measured on live inference against the same deployed model, same theme, no retraining:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Template rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Before the fix&lt;/td&gt;
&lt;td&gt;36% (9 of 25)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After the fix&lt;/td&gt;
&lt;td&gt;8% (1 of 12)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sample sizes are small and I'd want more data before treating 8% as precise. But the direction is unambiguous, and it cost one prompt edit rather than a retrain cycle.&lt;/p&gt;

&lt;p&gt;The remaining 8% is, I think, genuinely the weights. That 0.3% is still in there, and a prompt fix can't reach it. That residue is a legitimate item for the next training-data scrub. The difference is that it's now a small, well-understood cleanup rather than a retrain I'd have run on a wrong diagnosis.&lt;/p&gt;

&lt;h2&gt;
  
  
  The generalizable part
&lt;/h2&gt;

&lt;p&gt;When a fine-tuned model produces something repetitive, there are three possible causes and they have very different price tags:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The prompt is handing it the pattern.&lt;/strong&gt; Free to fix. &lt;em&gt;Check this first.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The training data is saturated with the pattern.&lt;/strong&gt; Needs a scrub and a retrain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The base model has a strong prior.&lt;/strong&gt; Needs data specifically counteracting it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The diagnostic that separates them is one line of &lt;code&gt;grep&lt;/code&gt; and one SQL query. &lt;strong&gt;Compare the rate in your training data to the rate in your output.&lt;/strong&gt; If the output rate is dramatically higher, the cause sits in your prompt or your retrieval layer, not your weights. If the two rates are similar, it really is a data problem and a retrain is justified.&lt;/p&gt;

&lt;p&gt;Two habits came out of this that I'd suggest to anyone running a fine-tune on Bedrock:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Persist your generations.&lt;/strong&gt; I could only run the "after" comparison because every output was already in a table with a timestamp. Without that history there is no measurement, only impressions, and impressions are what sent me toward a retrain in the first place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat prompt examples as training data.&lt;/strong&gt; They are, functionally. Anything concrete you put in a prompt as an illustration will get copied, so either give none, or give enough variety that only the underlying function is imitable. One example is the worst of both worlds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The uncomfortable version of this lesson: the model was fine. I nearly spent money and two days proving that the hard way, and the evidence that it was fine was sitting in a file I could have counted at any point.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>machinelearning</category>
      <category>ai</category>
      <category>llm</category>
    </item>
    <item>
      <title>Creating DNS with AWS Managed Microsoft AD and Managing Users in AWS Directory Service</title>
      <dc:creator>GargeeBhatnagar</dc:creator>
      <pubDate>Thu, 13 Aug 2026 10:56:09 +0000</pubDate>
      <link>https://dev.to/aws-builders/creating-dns-with-aws-managed-microsoft-ad-and-managing-users-in-aws-directory-service-5gka</link>
      <guid>https://dev.to/aws-builders/creating-dns-with-aws-managed-microsoft-ad-and-managing-users-in-aws-directory-service-5gka</guid>
      <description>&lt;p&gt;“ I have checked the documents of AWS for creating dns with aws managed microsoft ad and managing users in aws directory service. AWS Directory Service makes it easy for users and groups to be managed with dns creation. In terms of cost, the solution is cheaper and secure.”&lt;/p&gt;

&lt;p&gt;AWS Directory service provides multiple ways to use Microsoft Active Directory with other AWS services. Directories store information about users, groups and devices and administrators use them to manage access to information and resources. AWS Directory Service provides multiple directory choices for customers who want to use existing Microsoft AD or Lightweight Directory Access Protocol aware applications in the cloud. It also offers those same choices to developers who need a directory to manage users, groups, devices and access.&lt;/p&gt;

&lt;p&gt;In this post, you will experience creating dns with aws managed microsoft ad and managing users in aws directory service. Here I have created a directory with user.&lt;/p&gt;

&lt;h1&gt;
  
  
  Architecture Overview
&lt;/h1&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%2Fvn6jqt4ro79u9blqvkrx.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%2Fvn6jqt4ro79u9blqvkrx.png" alt=" " width="512" height="356"&gt;&lt;/a&gt;&lt;br&gt;
The architecture diagram shows the overall deployment architecture with data flow, aws directory service, users, domain controller, virtual private cloud.&lt;/p&gt;

&lt;h1&gt;
  
  
  Solution Overview
&lt;/h1&gt;

&lt;p&gt;The blog post consists of the following phases:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create of AWS Directory Service with AWS Managed Microsoft AD&lt;/li&gt;
&lt;li&gt;Create of User in the Directory&lt;/li&gt;
&lt;li&gt;Output of Share Directory to Other AWS Account&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Phase 1: Create of AWS Directory Service with AWS Managed Microsoft AD
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open the AWS Directory Service console, Create a directory with AWS Managed Microsoft AD. Select standard edition with input of required directory information. In addition to it with vpc and subnet info. The directory will be created successfully.&lt;/li&gt;
&lt;/ol&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%2F99jfvyaexspbj82vtvf1.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%2F99jfvyaexspbj82vtvf1.png" alt=" " width="800" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnex3dyhxuei7mo6ztsck.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%2Fnex3dyhxuei7mo6ztsck.png" alt=" " width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5y2v2f16kl1al9l3b05d.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%2F5y2v2f16kl1al9l3b05d.png" alt=" " width="800" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flvqljybmtqfj5pzwcveo.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%2Flvqljybmtqfj5pzwcveo.png" alt=" " width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbdgvj8fzmdbygxnduwap.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%2Fbdgvj8fzmdbygxnduwap.png" alt=" " width="800" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fic1g081neppocsiwhyzy.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%2Fic1g081neppocsiwhyzy.png" alt=" " width="800" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffrif70zgkpiowqkesaur.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%2Ffrif70zgkpiowqkesaur.png" alt=" " width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnvvdd9rw7vtiu2nu06qc.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%2Fnvvdd9rw7vtiu2nu06qc.png" alt=" " width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcsfs1gzl0c14ywhlrwu1.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%2Fcsfs1gzl0c14ywhlrwu1.png" alt=" " width="800" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6fww0kdohzk2a182c9yh.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%2F6fww0kdohzk2a182c9yh.png" alt=" " width="799" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuz4egf6wzbdpka7pdtbj.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%2Fuz4egf6wzbdpka7pdtbj.png" alt=" " width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 2: Create of User in the Directory
&lt;/h2&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%2F7vnaciddqaxj1dvnphr0.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%2F7vnaciddqaxj1dvnphr0.png" alt=" " width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1313h0y7ztxara0pujrx.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%2F1313h0y7ztxara0pujrx.png" alt=" " width="800" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjfx5futfxaxpdcdnhuzz.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%2Fjfx5futfxaxpdcdnhuzz.png" alt=" " width="800" height="439"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4u1633rvddb1yr1ed2bx.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%2F4u1633rvddb1yr1ed2bx.png" alt=" " width="800" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwk7jut863kqq8eksxmkp.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%2Fwk7jut863kqq8eksxmkp.png" alt=" " width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxbqckosg9lmpyt7t5hji.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%2Fxbqckosg9lmpyt7t5hji.png" alt=" " width="799" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmb8mtatiwu96el9ybcdv.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%2Fmb8mtatiwu96el9ybcdv.png" alt=" " width="799" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fve88w3i0hlpbl3evdzdf.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%2Fve88w3i0hlpbl3evdzdf.png" alt=" " width="800" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3cy0y91j6km7q247ilst.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%2F3cy0y91j6km7q247ilst.png" alt=" " width="800" height="439"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8l9ema55iz9eekhlxbj8.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%2F8l9ema55iz9eekhlxbj8.png" alt=" " width="800" height="439"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3c3bnu8ud8k4rdh8xbdp.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%2F3c3bnu8ud8k4rdh8xbdp.png" alt=" " width="799" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 3: Output of Share Directory to Other AWS Account
&lt;/h2&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%2Fg2jjh9myhzdi22ap0ewp.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%2Fg2jjh9myhzdi22ap0ewp.png" alt=" " width="800" height="439"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flh4knhlh1hrub1hu1ora.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%2Flh4knhlh1hrub1hu1ora.png" alt=" " width="800" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Clean-up
&lt;/h1&gt;

&lt;p&gt;Directory.&lt;/p&gt;

&lt;h1&gt;
  
  
  Pricing
&lt;/h1&gt;

&lt;p&gt;I review the pricing and estimated cost of this example.&lt;/p&gt;

&lt;p&gt;AWS Directory Service = $0.00 per hour for 1500 hours for Microsoft AD domain controllers usage under the global free trial = 1.519 Hrs x 0.00 = $0.00&lt;/p&gt;

&lt;p&gt;Total Cost = $0.00&lt;/p&gt;

&lt;h1&gt;
  
  
  Summary
&lt;/h1&gt;

&lt;p&gt;In this post, I showed “creating dns with aws managed microsoft ad and managing users in aw directory service”.&lt;/p&gt;

&lt;p&gt;For more details on AWS Directory Service, Checkout Get started AWS Directory Service, open the &lt;a href="https://us-east-1.console.aws.amazon.com/directoryservicev2/home?region=us-east-1#!/" rel="noopener noreferrer"&gt;AWS Directory Service console&lt;/a&gt;. To learn more, read the &lt;a href="https://docs.aws.amazon.com/directoryservice/latest/admin-guide/what_is.html" rel="noopener noreferrer"&gt;AWS Directory Service documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Thanks for reading!&lt;/p&gt;

&lt;p&gt;Connect with me: &lt;a href="https://www.linkedin.com/in/gargee-bhatnagar-6b7223114" rel="noopener noreferrer"&gt;Linkedin&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>awsdirectoryservice</category>
      <category>domaincontroller</category>
      <category>users</category>
    </item>
    <item>
      <title>AWS Advanced Certification Knowledge Framework</title>
      <dc:creator>Anuvindh</dc:creator>
      <pubDate>Thu, 13 Aug 2026 10:03:21 +0000</pubDate>
      <link>https://dev.to/aws-builders/aws-advanced-certification-knowledge-framework-kif</link>
      <guid>https://dev.to/aws-builders/aws-advanced-certification-knowledge-framework-kif</guid>
      <description>&lt;h2&gt;
  
  
  Who Is &lt;a href="https://aws.anuvindh.com/" rel="noopener noreferrer"&gt;ACKF&lt;/a&gt; For?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;AWS certification candidates&lt;/li&gt;
&lt;li&gt;Cloud engineers and Solutions Architects who want a structured way to understand AWS services&lt;/li&gt;
&lt;li&gt;DevOps, DevSecOps, SRE, and Cloud Operations professionals&lt;/li&gt;
&lt;li&gt;Students and professionals moving into AWS/cloud careers&lt;/li&gt;
&lt;li&gt;AWS trainers, mentors, and study groups looking for a repeatable learning structure&lt;/li&gt;
&lt;li&gt;Experienced AWS professionals preparing for recertification who need a faster way to review and recall concepts&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Introducing &lt;a href="https://aws.anuvindh.com/" rel="noopener noreferrer"&gt;ACKF&lt;/a&gt; Powered by the CLEAR™ Methodology
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://aws.anuvindh.com/" rel="noopener noreferrer"&gt;Official site link&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqobxphj9xhx400bk2kyh.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%2Fqobxphj9xhx400bk2kyh.png" alt=" " width="800" height="438"&gt;&lt;/a&gt;&lt;br&gt;
AWS certification preparation can quickly become overwhelming.&lt;/p&gt;

&lt;p&gt;There are hundreds of services, architectural patterns, limitations, integrations, pricing models, security considerations, and exam scenarios to understand.&lt;/p&gt;

&lt;p&gt;Most learners do not struggle because information is unavailable.&lt;/p&gt;

&lt;p&gt;They struggle because the information is fragmented.&lt;/p&gt;

&lt;p&gt;You may study AWS documentation, watch training videos, complete labs, read whitepapers, create notes, and practise exam questions. However, without a consistent structure, it can become difficult to connect everything together and recall the right information when it matters.&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%2F2xx8i80rng2liy72539x.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%2F2xx8i80rng2liy72539x.png" alt=" " width="800" height="431"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This challenge led me to create:&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;a href="https://aws.anuvindh.com/#framework" rel="noopener noreferrer"&gt;ACKF - AWS Advanced Certification Knowledge Framework&lt;/a&gt;
&lt;/h2&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%2Fyetja1qy19qlxhi1ovmk.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%2Fyetja1qy19qlxhi1ovmk.png" alt=" " width="800" height="183"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://aws.anuvindh.com/" rel="noopener noreferrer"&gt;ACKF&lt;/a&gt; is powered by the CLEAR™ Methodology:&lt;/p&gt;

&lt;p&gt;C — Compare similar AWS services&lt;br&gt;
L — Limitations that affect design choices&lt;br&gt;
E — Exam Factors hidden within scenarios&lt;br&gt;
A — Architecture placement and integrations&lt;br&gt;
R — Review and Recall for stronger retention&lt;/p&gt;

&lt;p&gt;The goal is simple:&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%2F7wwlh50jubjn1xrw8bus.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%2F7wwlh50jubjn1xrw8bus.png" alt=" " width="800" height="431"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Help learners move beyond memorising AWS services and start understanding how to compare, select, and apply them in real architectures.&lt;/p&gt;

&lt;p&gt;This framework supported my own AWS certification and recertification journey. Version 1.0 is now public for learners, cloud engineers, architects, trainers, and the wider AWS community to explore and improve.&lt;/p&gt;

&lt;p&gt;Example : &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%2Fuzjv27a5deqdmgx3u7k6.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%2Fuzjv27a5deqdmgx3u7k6.png" alt=" " width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;ACKF is an independent educational project and is not an official AWS training programme.&lt;/p&gt;

&lt;p&gt;I would value feedback from AWS learners, educators, cloud engineers, and solutions architects.&lt;/p&gt;

&lt;p&gt;Explore the project:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/anuvindhs/ACKF-Powerd-by-CLEAR-Methodology" rel="noopener noreferrer"&gt;https://github.com/anuvindhs/ACKF-Powerd-by-CLEAR-Methodology&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Explore content for &lt;a href="https://aws.anuvindh.com/cert/SAP" rel="noopener noreferrer"&gt;AWS Solutions Architect Professional&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;More certifications content coming soon &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;One Framework. One Method. Every Service.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>aws</category>
      <category>certification</category>
      <category>learning</category>
    </item>
  </channel>
</rss>
