DEV Community

Cover image for I Attacked My Own AWS API Four Times, Then Fixed It, and Wrote Down Everything
Yvan SAF
Yvan SAF

Posted on

I Attacked My Own AWS API Four Times, Then Fixed It, and Wrote Down Everything

A few months ago I got tired of hearing the same sentence in interviews and student forums: "we're on the cloud, so we're secure." 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 Shared Responsibility Model 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.

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

This is not a theoretical write-up. Every screenshot below came from a real request against real infrastructure I own, tested in line with AWS's penetration testing policy, 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: github.com/YvanSaf/aws-serverless-todo-api

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.

What I built

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.

Both versions share the same basic shape:

Client → API Gateway → Lambda → DynamoDB
Enter fullscreen mode Exit fullscreen mode

Vulnerable version:

Vulnerable architecture diagram

Hardened version:

Hardened architecture diagram

Same boxes. What changes is what sits between them, and what each one is allowed to do.

Attack 1: reading everyone's data with zero credentials (Enumeration)

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 enumeration.

Here's what that looks like in code. The vulnerable handler's "list tasks" function does this:

def list_tasks():
    result = table.scan()
    items = result.get("Items", [])
    return response(200, {"tasks": items, "count": len(items)})
Enter fullscreen mode Exit fullscreen mode

A Scan 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.

I created tasks for two different fictional users, then called the endpoint anonymously:

Enumeration succeeding on the vulnerable version, both users' tasks returned together
View this file in the repo

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.

On the hardened version, a Lambda Authorizer checks a signed token before the request goes anywhere near the handler:

The same enumeration attempt rejected before it reaches the data
View this file in the repo

401, 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.

Attack 2: planting a script tag in the database (Stored XSS)

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 Cross-Site Scripting, XSS 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.

In this project, the vulnerable version writes whatever it receives, exactly as received:

item = {
    "taskId": task_id,
    "userId": user_id,
    "title": title,          # whatever the client sent, unmodified
    "description": description,
    ...
}
table.put_item(Item=item)
Enter fullscreen mode Exit fullscreen mode

I sent <script>alert(document.cookie)</script> as a task title:

The script tag accepted and stored as the task title
View this file in the repo

And here it is sitting in DynamoDB, untouched:

The raw script tag stored unescaped in the database
View this file in the repo

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 "title" 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.

The hardened version runs every field through a validator before any of it reaches the database:

DANGEROUS_PATTERN = re.compile(
    r"<\s*script|<\s*/\s*script|<[^>]+>|javascript:|on\w+\s*=",
    re.IGNORECASE,
)
Enter fullscreen mode Exit fullscreen mode

The same payload rejected with a 400 before reaching the database
View this file in the repo

400, clear error message, nothing written.

Attack 3: touching someone else's data by guessing an ID (IDOR)

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 IDOR, short for Insecure Direct Object Reference. Almost every system identifies individual pieces of data with some kind of ID, an order number, a file ID, a task ID. That's completely normal and necessary. The vulnerability shows up when the system checks "does an item with this ID exist" but never checks "does this ID actually belong to the person asking for it."

Here's the vulnerable delete function in full:

def delete_task(task_id):
    table.delete_item(Key={"taskId": task_id})
    return response(200, {"message": "Task deleted", "taskId": task_id})
Enter fullscreen mode Exit fullscreen mode

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:

Reading another user's task with no ownership check
View this file in the repo

Deleting that same task, again with no ownership check
View this file in the repo

Both went through with a plain 200. 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.

The fix in the hardened handler is one comparison, added right before the delete happens:

if item.get("userId") != user_id:
    return response(403, {"error": "Forbidden"})
Enter fullscreen mode Exit fullscreen mode

The same read and delete attempts, both rejected with 403 Forbidden
View this file in the repo

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.

Attack 4: running up the bill (Scraping and Cost Abuse)

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 rate limiting or throttling: a rule that says, past a certain number of requests in a given time window, slow down or stop entirely.

The vulnerable API Gateway has no throttling configured anywhere. I fired 5000 requests at it back to back:

Nearly all 5000 requests succeeding with no rate limiting
View this file in the repo

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:

CloudWatch showing the invocation spike from the scraping attack
View this file in the repo

Every one of those 5000 invocations gets billed. 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.

The hardened stage has throttling configured directly on API Gateway. Past a certain request rate, this is what a client sees:

A request rejected with 429 once the rate limit is exceeded
View this file in the repo

429, 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.

Making a legitimate request traceable

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 X-Ray trace from API Gateway, through the authorizer, into the Lambda function, down to DynamoDB:

X-Ray service map showing the full request path
View this file in the repo

And every log line carries the trace ID linking it back to that exact request:

A CloudWatch log line correlated with its X-Ray trace ID
View this file in the repo

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.

A few things that actually tripped me up

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 docs/lessons-learned.md, and three of these are worth mentioning here.

At one point every single request in my rate-limit test came back 403 Forbidden instead of the 429 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.

Later, I configured API Gateway to throttle at 100 requests per second, sent enough traffic to trigger it, and got a wall of 503 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.

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.

Why I'm writing this down at all

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.

The security pillar of the AWS Well-Architected Framework, and the Cloud Adoption Framework 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.

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

Everything referenced here, Terraform, Python, attack scripts, screenshots, is public: github.com/YvanSaf/aws-serverless-todo-api. The repository also has two tagged releases, 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 main 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.


Yvan SAF
AWS Certified Cloud Practitioner | AWS Certified Solutions Architect Associate
Cameroon | Cloud Security | DevSecOps

Top comments (1)

Collapse
 
raknaos profile image
Raknaos

The 403-vs-401 asymmetry is the part nobody forecasts. A Lambda Authorizer that actively denies produces 403 through API Gateway's default response, so an expired token and a policy rejection look identical from the client side and the status code alone can't tell you which one to debug. Your wall of 503s is the same trap from the other end: the account's concurrency ceiling sat below the limit being measured, so the test proved nothing about the throttle.

Did you end up pinning the hardening stage to a separate account or region so the throttle could actually be reached? And what did you do about the secret in the exported shell command — is signing material now only ever read from a file descriptor, or is that still a manual habit you have to keep?