<?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: Yvan SAF</title>
    <description>The latest articles on DEV Community by Yvan SAF (@yvan_saf_ffc94f53623480b1).</description>
    <link>https://dev.to/yvan_saf_ffc94f53623480b1</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3893129%2F1e1cbbf7-0f16-4eac-b864-a90522b709b8.png</url>
      <title>DEV Community: Yvan SAF</title>
      <link>https://dev.to/yvan_saf_ffc94f53623480b1</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yvan_saf_ffc94f53623480b1"/>
    <language>en</language>
    <item>
      <title>I Attacked My Own AWS API Four Times, Then Fixed It, and Wrote Down Everything</title>
      <dc:creator>Yvan SAF</dc:creator>
      <pubDate>Mon, 14 Sep 2026 20:47:44 +0000</pubDate>
      <link>https://dev.to/yvan_saf_ffc94f53623480b1/i-attacked-my-own-aws-api-four-times-then-fixed-it-and-wrote-down-everything-3a52</link>
      <guid>https://dev.to/yvan_saf_ffc94f53623480b1/i-attacked-my-own-aws-api-four-times-then-fixed-it-and-wrote-down-everything-3a52</guid>
      <description>&lt;p&gt;A few months ago I got tired of hearing the same sentence in interviews and student forums: "&lt;strong&gt;we're on the cloud, so we're secure.&lt;/strong&gt;" I understand where it comes from. AWS handles the physical data centers, the hypervisor, the network backbone, a huge amount of infrastructure most of us never think about. But the &lt;strong&gt;Shared Responsibility Model&lt;/strong&gt; draws a line, and everything on your side of that line, your API's authentication, your IAM permissions, your rate limits, is on you. Most people who work with cloud infrastructure can recite that sentence. Far fewer have actually watched what happens when their side of the line is left empty.&lt;/p&gt;

&lt;p&gt;So I decided to build that gap and watch it myself. I deployed a small Todo API twice on the same AWS account: &lt;strong&gt;once with no security controls at all, and once hardened the way I'd actually build it for a real client&lt;/strong&gt;. Then I attacked both versions from my own terminal, kept every screenshot, and wrote down what broke and what held.&lt;/p&gt;

&lt;p&gt;This is not a theoretical write-up. Every screenshot below came from a real request against real infrastructure I own, tested in line with &lt;a href="https://aws.amazon.com/security/penetration-testing/" rel="noopener noreferrer"&gt;AWS's penetration testing policy&lt;/a&gt;, which permits testing resources you control without prior authorization. The full code, Terraform files, and attack scripts are here if you want to run this yourself: &lt;a href="https://github.com/YvanSaf/aws-serverless-todo-api" rel="noopener noreferrer"&gt;github.com/YvanSaf/aws-serverless-todo-api&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I'm also going to explain each attack in plain terms before showing it in action, so this is readable whether or not security is your specialty. If you already know what enumeration or IDOR means, feel free to skip ahead to the demonstrations.&lt;/p&gt;

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

&lt;p&gt;The application itself is deliberately boring: a Todo API. Create a task, list your tasks, read one, update it, delete it. I kept the business logic this simple on purpose, because the interesting part of this project has nothing to do with to-do lists. It's everything around the logic that either protects it or doesn't.&lt;/p&gt;

&lt;p&gt;Both versions share the same basic shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client → API Gateway → Lambda → DynamoDB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Vulnerable version&lt;/strong&gt;:&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Hardened version&lt;/strong&gt;:&lt;/p&gt;

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

&lt;p&gt;Same boxes. What changes is what sits between them, and what each one is allowed to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attack 1: reading everyone's data with zero credentials (&lt;strong&gt;Enumeration&lt;/strong&gt;)
&lt;/h2&gt;

&lt;p&gt;Let's start with the simplest idea in this whole article. Imagine a library where you ask the librarian for one specific book. Instead of handing you that book, she hands you the entire shelf, every book, every reader's name written inside, because nobody ever told her to check what you actually asked for or who you are. That's roughly what happens when an API endpoint pulls all the records in a database and returns them to whoever asks, without checking who is asking or filtering the result down to what that person is actually allowed to see. In security terms this general pattern of an application handing back more data than the requester should get, often by iterating through or dumping every record, is called &lt;strong&gt;enumeration&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here's what that looks like in code. The vulnerable handler's "list tasks" function does this:&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;def&lt;/span&gt; &lt;span class="nf"&gt;list_tasks&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scan&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Items&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tasks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;count&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;items&lt;/span&gt;&lt;span class="p"&gt;)})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;Scan&lt;/code&gt; reads the whole table. There's no filter by user because the route has no idea who's calling it, and no authentication either, so anyone with the URL can call it.&lt;/p&gt;

&lt;p&gt;I created tasks for two different fictional users, then called the endpoint anonymously:&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%2Fcrdunovfh6ak4sxq97pu.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%2Fcrdunovfh6ak4sxq97pu.png" alt="Enumeration succeeding on the vulnerable version, both users' tasks returned together" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;&lt;a href="https://github.com/YvanSaf/aws-serverless-todo-api/blob/main/forensic/findings/vulnerable/attack-01-enumeration.png" rel="noopener noreferrer"&gt;View this file in the repo&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One anonymous request, the whole dataset back. Nothing about this required skill on my part. It's what you get by default when nobody puts a lock on the door.&lt;/p&gt;

&lt;p&gt;On the hardened version, a Lambda Authorizer checks a signed token before the request goes anywhere near the handler:&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%2Fkkp5voxkktbrwmjydkax.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%2Fkkp5voxkktbrwmjydkax.png" alt="The same enumeration attempt rejected before it reaches the data" width="800" height="409"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;&lt;a href="https://github.com/YvanSaf/aws-serverless-todo-api/blob/main/forensic/findings/hardened/attack-08-401-no-token.png" rel="noopener noreferrer"&gt;View this file in the repo&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;401&lt;/strong&gt;, request rejected before it reaches the table. And when a real, authenticated user calls this same route, the hardened handler only queries that user's own tasks through a DynamoDB index, so even a legitimate call never returns someone else's data.&lt;/p&gt;
&lt;h2&gt;
  
  
  Attack 2: planting a script tag in the database (&lt;strong&gt;Stored XSS&lt;/strong&gt;)
&lt;/h2&gt;

&lt;p&gt;Here's a different kind of problem. Think of a public bulletin board where anyone can pin up a note for other people to read later. Now imagine someone pins up a note that isn't really a note, it's a trap: a business card wired to do something the moment someone else picks it up and reads it. The board itself didn't do anything wrong by letting people post notes, that's its whole purpose. The mistake was never checking what was actually written on the note before letting it sit there for the next visitor. This is the core idea behind &lt;strong&gt;Cross-Site Scripting&lt;/strong&gt;, &lt;strong&gt;XSS&lt;/strong&gt; for short: an application accepts text from one user, stores it, and later displays that same text to a different user without checking whether it's plain text or something a browser will actually try to run. When the stored text contains code and a browser executes it, the person who submitted it and the person who gets hurt by it are two completely different people.&lt;/p&gt;

&lt;p&gt;In this project, the &lt;strong&gt;vulnerable version&lt;/strong&gt; writes whatever it receives, exactly as received:&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;item&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;taskId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;userId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;# whatever the client sent, unmodified
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Item&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I sent &lt;code&gt;&amp;lt;script&amp;gt;alert(document.cookie)&amp;lt;/script&amp;gt;&lt;/code&gt; as a task title:&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%2F2bepwps56123vqt5p18z.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%2F2bepwps56123vqt5p18z.png" alt="The script tag accepted and stored as the task title" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;&lt;a href="https://github.com/YvanSaf/aws-serverless-todo-api/blob/main/forensic/findings/vulnerable/attack-02-xss-injection.png" rel="noopener noreferrer"&gt;View this file in the repo&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And here it is sitting in DynamoDB, untouched:&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%2F9yhszeermv8isidx7u2z.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%2F9yhszeermv8isidx7u2z.png" alt="The raw script tag stored unescaped in the database" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;&lt;a href="https://github.com/YvanSaf/aws-serverless-todo-api/blob/main/forensic/findings/vulnerable/attack-03-xss-stored-dynamodb.png" rel="noopener noreferrer"&gt;View this file in the repo&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Nothing in this API renders HTML today, so this particular payload just sits there for now. But the moment anyone builds a frontend that displays this "&lt;strong&gt;title&lt;/strong&gt;" field on a page, whatever's stored here runs in that visitor's browser exactly as if it belonged there. The vulnerability isn't in the frontend that doesn't exist yet, it's in the API that never should have accepted this in the first place.&lt;/p&gt;

&lt;p&gt;The hardened version runs every field through a validator before any of it reaches the database:&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;DANGEROUS_PATTERN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&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;&amp;lt;\s*script|&amp;lt;\s*/\s*script|&amp;lt;[^&amp;gt;]+&amp;gt;|javascript:|on\w+\s*=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IGNORECASE&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;&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%2Fmnfozvjk88q154lsmi70.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%2Fmnfozvjk88q154lsmi70.png" alt="The same payload rejected with a 400 before reaching the database" width="800" height="409"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;&lt;a href="https://github.com/YvanSaf/aws-serverless-todo-api/blob/main/forensic/findings/hardened/attack-09-400-xss-blocked.png" rel="noopener noreferrer"&gt;View this file in the repo&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;400&lt;/strong&gt;, clear error message, nothing written.&lt;/p&gt;
&lt;h2&gt;
  
  
  Attack 3: touching someone else's data by guessing an ID (&lt;strong&gt;IDOR&lt;/strong&gt;)
&lt;/h2&gt;

&lt;p&gt;Picture a hotel where every room has its own keycard, but the front desk never actually checks whether the card you're holding matches the room number you're standing in front of. As long as you can slide a card into the reader and the door happens to open, you're in, regardless of whose name is on the reservation. That's the mechanism behind an attack called &lt;strong&gt;IDOR&lt;/strong&gt;, short for &lt;strong&gt;Insecure Direct Object Reference&lt;/strong&gt;. Almost every system identifies individual pieces of data with some kind of &lt;strong&gt;ID, an order number, a file ID, a task ID&lt;/strong&gt;. That's completely normal and necessary. The vulnerability shows up when the system checks "&lt;strong&gt;does an item with this ID exist&lt;/strong&gt;" but never checks "&lt;strong&gt;does this ID actually belong to the person asking for it.&lt;/strong&gt;"&lt;/p&gt;

&lt;p&gt;Here's the vulnerable delete function in full:&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;def&lt;/span&gt; &lt;span class="nf"&gt;delete_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;taskId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&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;Task deleted&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;taskId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire function. No check on who's asking. I created a task belonging to a fictional victim, then, as a completely unrelated attacker, read it and deleted 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%2Fti18j01zvsltyf1zeuw8.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%2Fti18j01zvsltyf1zeuw8.png" alt="Reading another user's task with no ownership check" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;&lt;a href="https://github.com/YvanSaf/aws-serverless-todo-api/blob/main/forensic/findings/vulnerable/attack-04-idor-read.png" rel="noopener noreferrer"&gt;View this file in the repo&lt;/a&gt;&lt;/em&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%2Fup1rn998k2b1ntf642fy.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%2Fup1rn998k2b1ntf642fy.png" alt="Deleting that same task, again with no ownership check" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;&lt;a href="https://github.com/YvanSaf/aws-serverless-todo-api/blob/main/forensic/findings/vulnerable/attack-05-idor-delete.png" rel="noopener noreferrer"&gt;View this file in the repo&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Both went through with a plain &lt;strong&gt;200&lt;/strong&gt;. And remember attack 1 handed out every task ID in the table for free, so in practice these two issues chain together: one request to harvest IDs, a second to act on any of them.&lt;/p&gt;

&lt;p&gt;The fix in the hardened handler is one comparison, added right before the delete happens:&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;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;userId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&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;Forbidden&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;&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%2Foxmthcy48desrcivonai.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%2Foxmthcy48desrcivonai.png" alt="The same read and delete attempts, both rejected with 403 Forbidden" width="800" height="409"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;&lt;a href="https://github.com/YvanSaf/aws-serverless-todo-api/blob/main/forensic/findings/hardened/attack-10-403-idor-blocked.png" rel="noopener noreferrer"&gt;View this file in the repo&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I keep coming back to how small that fix is. This wasn't missing some elaborate access control system, it was missing a single question that was never asked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attack 4: running up the bill (&lt;strong&gt;Scraping and Cost Abuse&lt;/strong&gt;)
&lt;/h2&gt;

&lt;p&gt;Last one, and it's less about data and more about money and availability. Every request to an API costs something, a bit of compute time, a database read, a fraction of a cent. That's fine when the number of requests is reasonable. It stops being fine when nothing stops one person from sending thousands of requests per second. Two things tend to happen at once: the target either gets its entire dataset scraped out at high speed, or the owner of that API opens their AWS bill at the end of the month and finds a number they didn't expect, generated entirely by someone else's traffic. The usual fix is called &lt;strong&gt;rate limiting or throttling&lt;/strong&gt;: a rule that says, past a certain number of requests in a given time window, slow down or stop entirely.&lt;/p&gt;

&lt;p&gt;The vulnerable API Gateway has no throttling configured anywhere. I fired &lt;strong&gt;5000 requests&lt;/strong&gt; at it back to back:&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%2Fqe7h0h51ri7lzy8aofsy.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%2Fqe7h0h51ri7lzy8aofsy.png" alt="Nearly all 5000 requests succeeding with no rate limiting" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;&lt;a href="https://github.com/YvanSaf/aws-serverless-todo-api/blob/main/forensic/findings/vulnerable/attack-06-scraping-requests.png" rel="noopener noreferrer"&gt;View this file in the repo&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A handful of these failed, but not because anything was protecting the API. My own AWS account's default Lambda concurrency limit briefly got saturated, which is an accident of account configuration, not a defense anyone designed. CloudWatch shows the invocation spike this caused:&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%2Frw9ihjou4t2oq6akn9uq.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%2Frw9ihjou4t2oq6akn9uq.png" alt="CloudWatch showing the invocation spike from the scraping attack" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;&lt;a href="https://github.com/YvanSaf/aws-serverless-todo-api/blob/main/forensic/findings/vulnerable/attack-07-scraping-cloudwatch.png" rel="noopener noreferrer"&gt;View this file in the repo&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every one of those &lt;strong&gt;5000 invocations gets bille&lt;/strong&gt;d. An API with no rate limit isn't only a data exposure problem, it's also a way to let someone else decide how big your AWS bill gets this month.&lt;/p&gt;

&lt;p&gt;The hardened stage has throttling configured directly on API Gateway. Past a certain request rate, this is what a client sees:&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%2Frx573dq8sjfg6an85du4.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%2Frx573dq8sjfg6an85du4.png" alt="A request rejected with 429 once the rate limit is exceeded" width="800" height="309"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;&lt;a href="https://github.com/YvanSaf/aws-serverless-todo-api/blob/main/forensic/findings/hardened/attack-11-429-rate-limited.png" rel="noopener noreferrer"&gt;View this file in the repo&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;429&lt;/strong&gt;, on purpose, at a threshold I chose. The difference between this and the account ceiling I hit earlier is worth sitting with for a second: one of them I designed, the other one I just happened to run into.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making a legitimate request traceable
&lt;/h2&gt;

&lt;p&gt;Stopping attacks matters, but it isn't the whole job. If something goes wrong later, you need to be able to reconstruct what happened, and the hardened version was built with that in mind from the start. Every request carries an &lt;strong&gt;X-Ray&lt;/strong&gt; &lt;strong&gt;trace&lt;/strong&gt; from API Gateway, through the authorizer, into the Lambda function, down to DynamoDB:&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%2Fqd0c0n0rc26di9gs3rh2.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%2Fqd0c0n0rc26di9gs3rh2.png" alt="X-Ray service map showing the full request path" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;&lt;a href="https://github.com/YvanSaf/aws-serverless-todo-api/blob/main/forensic/findings/hardened/attack-12-xray-trace.png" rel="noopener noreferrer"&gt;View this file in the repo&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And every log line carries the &lt;strong&gt;trace ID linking&lt;/strong&gt; it back to that exact request:&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%2Fpanoqmm51mkwurisa1ix.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%2Fpanoqmm51mkwurisa1ix.png" alt="A CloudWatch log line correlated with its X-Ray trace ID" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;&lt;a href="https://github.com/YvanSaf/aws-serverless-todo-api/blob/main/forensic/findings/hardened/attack-13-cloudwatch-structured-logs.png" rel="noopener noreferrer"&gt;View this file in the repo&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I think this part gets skipped a lot in security demos. Blocking the bad request is only half of it. Being able to answer, afterward, exactly what happened and to which resource is the other half, and it's easy to forget about until you actually need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few things that actually tripped me up
&lt;/h2&gt;

&lt;p&gt;The four comparisons above look tidy now that they're written down. Getting there wasn't. I kept a running log of what went wrong in &lt;a href="https://github.com/YvanSaf/aws-serverless-todo-api/blob/main/docs/lessons-learned.md" rel="noopener noreferrer"&gt;&lt;code&gt;docs/lessons-learned.md&lt;/code&gt;&lt;/a&gt;, and three of these are worth mentioning here.&lt;/p&gt;

&lt;p&gt;At one point every single request in my rate-limit test came back &lt;code&gt;403 Forbidden&lt;/code&gt; instead of the &lt;code&gt;429&lt;/code&gt; I expected. My first guess was a bug in the throttling config. It turned out my test token had simply expired. On HTTP APIs, a Lambda Authorizer that actively denies a request produces a 403 through API Gateway's default response, while a request with no token at all produces a 401. Same rejection, two different causes, and the status code by itself doesn't tell you which one you're looking at.&lt;/p&gt;

&lt;p&gt;Later, I configured API Gateway to throttle at &lt;strong&gt;100 requests per second&lt;/strong&gt;, sent enough traffic to trigger it, and got a wall of &lt;code&gt;503&lt;/code&gt; errors instead. My personal AWS account's default Lambda concurrency limit was lower than the throttle I'd configured, so my test traffic hit that ceiling first and never built up enough sustained volume to reach the limit I actually cared about testing. I ended up lowering the throttle for testing purposes and writing down why, since a future reader with a different account's limits would hit a different wall entirely.&lt;/p&gt;

&lt;p&gt;And at some point, while capturing a terminal screenshot, my JWT signing secret showed up in plain text in an exported shell command. I caught it and blurred it before sharing anything, but the lesson that actually stuck wasn't about redacting screenshots after the fact, it was about not typing secrets inline on the command line in the first place, where a shell history or a screenshot can grab them without you noticing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I'm writing this down at all
&lt;/h2&gt;

&lt;p&gt;Every attack in this article is well known. None of them would surprise a security engineer. That's exactly why I picked them: they're common enough that a lot of unprotected APIs on the internet right now are exposed to some version of this list, without anyone attacking them on purpose yet.&lt;/p&gt;

&lt;p&gt;The security pillar of the &lt;strong&gt;AWS Well-Architected Framework&lt;/strong&gt;, and the &lt;strong&gt;Cloud Adoption Framework&lt;/strong&gt; around it, don't treat security as something you check at the end. They treat it as part of the same set of decisions as everything else you're building, because adding authentication, ownership checks, encryption, and rate limits after a system is already running is always harder than building them in from day one.&lt;/p&gt;

&lt;p&gt;You don't need to specialize in security to take one thing from this: &lt;strong&gt;the cloud provider secures the ground you're standing on&lt;/strong&gt;. What you build on top of that ground is yours to get right, and left alone, it defaults to wide open.&lt;/p&gt;

&lt;p&gt;Everything referenced here, &lt;strong&gt;Terraform, Python, attack scripts, screenshots&lt;/strong&gt;, is public: &lt;a href="https://github.com/YvanSaf/aws-serverless-todo-api" rel="noopener noreferrer"&gt;github.com/YvanSaf/aws-serverless-todo-api&lt;/a&gt;. The repository also has two &lt;a href="https://github.com/YvanSaf/aws-serverless-todo-api/releases" rel="noopener noreferrer"&gt;tagged releases&lt;/a&gt;, one marking the vulnerable version as complete, the other marking the hardened version as complete, if you want to check out the exact state of the project at either milestone instead of the current &lt;code&gt;main&lt;/code&gt; branch. If you have a sandbox AWS account sitting around, clone it, break it, fix it. That's a more useful hour than reading about it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Yvan SAF&lt;/em&gt;&lt;br&gt;
&lt;em&gt;AWS Certified Cloud Practitioner | AWS Certified Solutions Architect Associate&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Cameroon | Cloud Security | DevSecOps&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>aws</category>
      <category>cybersecurity</category>
      <category>cloudsecurity</category>
    </item>
    <item>
      <title>SNS vs EventBridge: the 100-point cap nobody talks about</title>
      <dc:creator>Yvan SAF</dc:creator>
      <pubDate>Wed, 29 Jul 2026 02:26:44 +0000</pubDate>
      <link>https://dev.to/yvan_saf_ffc94f53623480b1/sns-vs-eventbridge-the-100-point-cap-nobody-talks-about-2gah</link>
      <guid>https://dev.to/yvan_saf_ffc94f53623480b1/sns-vs-eventbridge-the-100-point-cap-nobody-talks-about-2gah</guid>
      <description>&lt;h1&gt;
  
  
  SNS vs EventBridge: the 100-point cap nobody talks about
&lt;/h1&gt;

&lt;p&gt;I've been digging into AWS in depth lately. At some point I got stuck on something: EventBridge and SNS seemed to do exactly the same thing. Both receive something, filter it, distribute it. Every article I read gave me "it depends on the use case" without ever showing a real, measurable limit.&lt;/p&gt;

&lt;p&gt;So I stopped reading comparison posts and went straight to the AWS docs, plus a couple of GitHub issues. Here's what came out of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The simple case: yeah, they do the same thing
&lt;/h2&gt;

&lt;p&gt;Let's get this out of the way first. If your need is filtering on two or three flat fields (country, priority, amount), SNS and EventBridge do strictly the same thing. No technical advantage of one over the other. The choice becomes a matter of organization, not technology.&lt;/p&gt;

&lt;p&gt;The real question is where each one hits its ceiling, and whether your project will ever get close to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  SNS has a mathematical limit AWS admits itself
&lt;/h2&gt;

&lt;p&gt;On &lt;a href="https://repost.aws/knowledge-center/sns-troubleshoot-message-filtering" rel="noopener noreferrer"&gt;its SNS message filtering troubleshooting page&lt;/a&gt;, AWS states plainly that if your pattern matching needs exceed SNS's filtering capabilities, you should switch to EventBridge rules. That's not me inventing a hierarchy, it's written in their own docs.&lt;/p&gt;

&lt;p&gt;And that ceiling is a points system. A kind of complexity budget you're not supposed to exceed.&lt;/p&gt;

&lt;p&gt;The rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a pattern with a single wildcard = 1 point&lt;/li&gt;
&lt;li&gt;a pattern with multiple wildcards = 3 points per wildcard&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example. &lt;code&gt;"pay*"&lt;/code&gt; has one wildcard, that's 1 point. &lt;code&gt;"pay*ment*"&lt;/code&gt; has two wildcards in the same pattern, that's 2 x 3 = 6 points.&lt;/p&gt;

&lt;p&gt;You add all of that up field by field, then across all fields, and the total can't exceed 100 points for the whole policy. Go over that, and AWS simply refuses to create your filter policy.&lt;/p&gt;

&lt;p&gt;Take two fields:&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;"country"&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="s2"&gt;"CM*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"US*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FR*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reference"&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="s2"&gt;"INV-*-2024-*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ORD-*-2024-*"&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;&lt;code&gt;country&lt;/code&gt;: 3 patterns with 1 wildcard each = 3 points.&lt;br&gt;
&lt;code&gt;reference&lt;/code&gt;: 2 patterns with 2 wildcards each = 2 x 6 = 12 points.&lt;br&gt;
Total: 15 points out of the 100 allowed.&lt;/p&gt;

&lt;p&gt;You can see how in 95% of real cases you'll never get close to this limit. But it exists, it's documented &lt;a href="https://docs.aws.amazon.com/sns/latest/dg/subscription-filter-policy-constraints.html" rel="noopener noreferrer"&gt;on the filter policy constraints page&lt;/a&gt;, and EventBridge has nothing comparable.&lt;/p&gt;

&lt;p&gt;That same page lists other hard numbers: 5 keys max per filter policy, 256 KB max size, 200 filter policies per topic by default (10,000 per account).&lt;/p&gt;

&lt;h2&gt;
  
  
  Nested JSON, where I was half wrong
&lt;/h2&gt;

&lt;p&gt;My starting line was "SNS can't filter nested JSON, EventBridge can." Wrong, or at least incomplete.&lt;/p&gt;

&lt;p&gt;SNS has two filtering modes. The default mode, based on message attributes, genuinely doesn't support nested structures, that's stated clearly in the constraints doc mentioned above. But since 2022 there's a second mode, based on the entire message body (&lt;code&gt;FilterPolicyScope: MessageBody&lt;/code&gt;), that lets you filter on nested fields without duplicating that data into separate attributes. &lt;a href="https://cloudtoolstack.com/learn/aws-sqs-sns-eventbridge-guide" rel="noopener noreferrer"&gt;CloudToolStack breaks it down well&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So the honest answer is: SNS can, as long as you turn on the right mode. And to complicate things further, there's &lt;a href="https://github.com/aws/serverless-application-model/issues/3214" rel="noopener noreferrer"&gt;an open issue on AWS SAM's GitHub repo&lt;/a&gt; documenting a specific bug: when a filter policy mixes fields at different nesting depths (say a flat &lt;code&gt;status&lt;/code&gt; field alongside a nested &lt;code&gt;before.owner&lt;/code&gt; field in the same policy), the deployment tool fails at creation time, even though SNS itself is supposed to support this. The kind of detail you only find by digging, never in a generic blog comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually still different: the vocabulary of the filter
&lt;/h2&gt;

&lt;p&gt;Even with the &lt;code&gt;MessageBody&lt;/code&gt; mode turned on, a real gap remains. EventBridge supports "or"-style logical combinations across multiple fields, something like "match if country = CM and priority = high, or if amount &amp;gt; 1000". SNS has no inter-field combination operator like that.&lt;/p&gt;

&lt;p&gt;SNS also has a syntax constraint: you can't mix an &lt;code&gt;anything-but&lt;/code&gt; operator with a prefix operator in the same element, they need to be separate objects in the array. EventBridge additionally supports matching on IP ranges in CIDR notation, which SNS doesn't have at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Subscriber count, a number that's easy to misread
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://medium.com/awesome-cloud/aws-difference-between-amazon-eventbridge-and-amazon-sns-46708bf5313" rel="noopener noreferrer"&gt;A comparison by Ashish Patel on Medium&lt;/a&gt; gives numbers that reset the picture. EventBridge: 300 rules per bus, 5 targets per rule. SNS: up to 2,500,000 subscriptions per standard topic, 100 per subscription on FIFO.&lt;/p&gt;

&lt;p&gt;On paper this would make EventBridge look "small". It's actually the opposite: SNS crushes it on raw volume of identical subscribers, which makes it a great fit for massive fan-out to consumers that all do the same thing (thousands of identical SQS queues, for instance).&lt;/p&gt;

&lt;p&gt;SNS's real limit isn't volume, it's the variety of target types. SNS can only target SMS, email, Lambda, SQS, HTTP, and mobile push. EventBridge, on the other hand, is natively wired to over 130 event sources and can directly target more than 35 AWS services (Step Functions, Kinesis, ECS, CodePipeline, API Gateway...) without going through an intermediary.&lt;/p&gt;

&lt;h2&gt;
  
  
  What SNS does better, no bias here
&lt;/h2&gt;

&lt;p&gt;SNS sends an SMS or an email directly to a human. EventBridge can't do that on its own, it has to delegate to SNS or to a Lambda that calls SES.&lt;/p&gt;

&lt;p&gt;SNS also has a FIFO variant that guarantees strict delivery order and no duplicates. Those FIFO topics only deliver to FIFO SQS queues, which makes it a solid fit for ordered fan-out to multiple pipelines. EventBridge doesn't guarantee any ordering, not even approximate.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'd actually decide
&lt;/h2&gt;

&lt;p&gt;Simple filtering, few wildcards, few combined fields → SNS is enough, no reason to overcomplicate.&lt;/p&gt;

&lt;p&gt;Risk of going past 100 points, or need for "or" operators across fields → EventBridge becomes necessary.&lt;/p&gt;

&lt;p&gt;Need to target varied services beyond Lambda/SQS → EventBridge.&lt;/p&gt;

&lt;p&gt;Need guaranteed delivery order with no exceptions → SNS FIFO, the only valid option between the two.&lt;/p&gt;

&lt;p&gt;Multiple teams that need to subscribe without ever having to ask you → EventBridge, because they create their own rules on the bus without going through you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;My initial skepticism held up on the simple case, but falls apart as soon as you push on filtering complexity or target diversity. And the most interesting part of this whole research trip is that the answer was already there, in the official docs, with exact numbers, just buried under layers of articles that prefer "it depends on the use case" over actually going and checking.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>sns</category>
      <category>eventbridge</category>
      <category>eventdriven</category>
    </item>
  </channel>
</rss>
