Threat examples
- Lesson 3
- 50 min
- Module 1: Identity and permission basics
Lesson 3 of 19 · Authentication, Permissions and Multi-Tenant ApplicationsThreat examples
1. Identity and permission basics
2. Understand login and tokens
3. Sessions and application integration
4. Enforce resource access
5. Background work and administration
6. Validate and document the design
What you will be able to do
See what goes wrong while the API checks only a shared key. A person reads another organization’s ticket by guessing its ID, and a read-only person changes a ticket. You try both on your own practice API, with made-up records.
Before you startWhat to know and to have ready
What you need to know
- The three questions, from Three separate questions, and the access matrix with its 403 and 404 answers, from Users and organizations.
curland the API’s routes/v1/tickets/{id},…/messagesand…/similar, from the API and databases courses.
Your project
~/projects/ticket-apiat the snapshotend-of-m01: both shops loaded,154 passed, 1 deselected.
Harmless and local. Every attempt in this lesson goes to the practice API on your own computer, with made-up records. Never try these requests against a system that you do not own, or without written permission.
A cloakroom and a staff door
Section titled “A cloakroom and a staff door”Think of a theatre cloakroom. You give your coat, and you get a ticket with the number 41. After the show, you show the ticket, and the attendant brings coat 41.
Now imagine a lazy attendant. He does not look at your ticket. He only asks, “Which number?” You say “42”, and he brings you a stranger’s coat. Nobody broke anything. The attendant did not check that coat 42 belongs to you.
The same theatre has a door with a sign: “Staff only”. The door is not locked. Most visitors respect the sign. A curious visitor opens the door anyway, and nothing stops him.
The ticket API has both problems today. In this lesson, you see each one with real requests.
Attempt 1: a guessed ticket ID
Section titled “Attempt 1: a guessed ticket ID”Sam works at Larkfield. He is curious about the new shop. Larkfield numbers its tickets T-30001 to T-30200, so Bramble’s tickets probably have similar numbers. Sam guesses T-40001.
-
Start the API with the key, in a second terminal with the virtual environment active:
Terminal window API_KEY=local-dev-key fastapi devOn Windows, set
$env:API_KEY = "local-dev-key"first, then runfastapi dev. -
In the first terminal, send Sam’s request. On Windows, type
curl.exe:Terminal window curl -H "X-API-Key: local-dev-key" http://127.0.0.1:8000/v1/tickets/T-40001{"ticket_id":"T-40001","customer_id":"B-20101","customer_name":"Eleanor Price","subject":"Charged twice for order BB-310729","body":"My card was charged two times for order BB-310729, 47.50 dollars each time. Please refund one of the payments.","team":"billing","priority":1,"status":"open","attachments":[{"attachment_id":"e7e54964-6167-47fa-8c59-b0b14b2d7e4a","file_name":"receipt-BB-310729.pdf","size_bytes":724, …}], …}The answer is shortened here. Sam now has a Bramble customer’s name, an order number, an amount, and the ID of their receipt.
-
Try a number that does not exist:
Terminal window curl -H "X-API-Key: local-dev-key" http://127.0.0.1:8000/v1/tickets/T-49999{"error":{"code":"not_found","message":"Ticket T-49999 does not exist.","request_id":"67f184db04b6","fields":[]}}
The two answers differ, so a curious person can try T-40001, T-40002 and so on, and list every ticket that exists. The IDs are easy to guess, because they count up.
Text description of the diagram
Sam sends GET /v1/tickets/T-40001 with the shared key. The API checks only that the key is right. Then it runs SELECT … FROM tickets WHERE ticket_id = 'T-40001', finds Bramble Books’ ticket, and returns it with 200 OK. The missing check is “is T-40001 in an organization where Sam is a member?” Nobody asks it.
This failure has a name. The API lets a caller reach an object, here a ticket, that the caller may not reach. It is an object-level access failure. Security lists call it BOLA, Broken Object Level Authorization. It is first in the OWASP API Security Top 10 (2023), a list of the most common API risks. Like the lazy attendant, the API trusts the number that the caller says.
Tomás suggests a fix: “Use random IDs, like the attachments. Nobody can guess those.” Look at step 2 again. The attachment ID is random, e7e54964-…, and the ticket’s answer gives it away. A random ID makes guessing harder, but it is not a check. An ID leaks through links, logs and screenshots. Then the next caller gets the record. The only fix is to check, for every record, that it belongs to an organization where the caller is a member.
Attempt 2: a read-only person writes
Section titled “Attempt 2: a read-only person writes”Omar is read-only at Larkfield. Imagine that the help desk’s web page hides the “Reply” button for read-only people. Omar does not need the page. He sends the request himself:
curl -X POST -H "X-API-Key: local-dev-key" -H "Content-Type: application/json" -d '{"author": "agent", "body": "Refund approved."}' http://127.0.0.1:8000/v1/tickets/T-30002/messages{"message_id":390,"author":"agent","body":"Refund approved.","created_at":"2026-10-10T06:10:54.369461Z"}In PowerShell, put the body in a file, msg.json, and use -d "@msg.json". The API saved the message: 201 Created. A customer now reads “Refund approved.” from a person who may not approve anything.
Text description of the diagram
Through the page, Omar sees ticket T-30002 without a Reply button, so he sends no request. Around the page, Omar sends the same POST request from his terminal. The API checks only the key, so it saves the message and answers 201 Created. The button is part of the page in the browser. The rule must be in the API, because every request reaches the API.
This is privilege escalation: a person does something that their role does not allow. Here, a read-only person acts like staff. The OWASP list calls the API’s side of it Broken Function Level Authorization. The API does not check whether this caller may use this function.
Tomás once said, “We hid the delete button, so read-only people cannot delete.” Now you can answer him. A hidden button is a kindness to the user: it hides what they cannot use. It is never the protection. The API must check the role on the server, for every request.
Leaks without a guess
Section titled “Leaks without a guess”Some failures need no guess at all. Ines owns Bramble Books. She lists the tickets “of her shop”:
curl -H "X-API-Key: local-dev-key" "http://127.0.0.1:8000/v1/tickets?limit=6"The six newest tickets come back, from both shops, mixed by time:
T-40040 B-20102 Change the delivery address of BB-310461T-30200 C-0025 wooden garden bench stopped workingT-30199 C-0027 Change delivery addressT-30198 C-0038 Merge two accountsT-40039 B-20110 Where is my order BB-310304?T-30197 C-0029 Payment failed but money takenEach row shows only three fields here. The list has no organization, so it returns everything. The similar-ticket search has the same problem. Ines asks for tickets similar to her T-40001, and two of the five come from Larkfield: T-30050 and T-30076, “Charged twice” tickets of the garden shop. The vectors are near each other because the problems are the same, and nothing keeps the shops apart.
The databases course showed an injection that broke a query built from strings, in Safe queries. Run its demo again:
python -m scripts.injection_demo "x' OR '1'='1"unsafe (string building): SQL sent: SELECT customer_id, name, email FROM customers WHERE email = 'x' OR '1'='1' 52 row(s), first: ('C-0001', 'Olga Olsen', 'olga.olsen@example.com')safe (parameter): SQL sent: SELECT customer_id, name, email FROM customers WHERE email = %s value sent separately: "x' OR '1'='1" 0 row(s)In the databases course, it returned Larkfield’s 40 customers. Now it returns 52: the customers of both shops. A bug that leaked one shop’s data now leaks two. The safe version with a parameter still returns 0 rows. Stop the API with Ctrl+C.
Run all five attempts
Section titled “Run all five attempts”The project has a script with five made-up attempts. It sends each request through the API’s own code, with the key, and judges the answer. It needs no running server.
python -m scripts.attemptsMade-up attempts against your own practice API. Every request is sent the same way(with the API key, if one is set): the API cannot tell who is calling.
1. Sam (staff at Larkfield) tries to read Bramble Books' ticket T-40001 by guessing its ID. GET /v1/tickets/T-40001 -> 200 (should get: 404) LEAK: "Charged twice for order BB-310729"2. Ines (owner at Bramble Books) tries to list the tickets of her own shop. GET /v1/tickets?limit=100 -> 200 (should get: only bramble) LEAK: 77 of 100 tickets are Larkfield's, e.g. T-302003. Omar (read-only at Larkfield) tries to add a message to T-30002. POST /v1/tickets/T-30002/messages -> 201 (should get: 403) ESCALATION: "(attempt: a read-only member writes)"4. Tomás (no membership) tries to read Larkfield's ticket T-30002. GET /v1/tickets/T-30002 -> 200 (should get: 404) LEAK: "Charged twice for order LK-182074"5. Ines (owner at Bramble Books) tries to find tickets similar to her ticket T-40001. GET /v1/tickets/T-40001/similar?k=5 -> 200 (should get: only bramble) LEAK: 2 of 5 tickets are Larkfield's, e.g. T-30050
5 of 5 attempts got what they should not get.The API also prints its own log lines between them. To see only the report, add 2>/dev/null on macOS and Linux, or 2>$null in PowerShell.
All five attempts get what they should not. Each one fails one of the three questions:
| Attempt | Should get | Got | The question that the API cannot answer |
|---|---|---|---|
| 1. Sam reads T-40001 | 404 | 200 | In which organization? Sam is not a Bramble member. |
| 2. Ines lists tickets | Bramble’s only | 77 of 100 are Larkfield’s | In which organization? The list has none. |
| 3. Omar writes | 403 | 201 | What may he do? read_only may not write. |
| 4. Tomás reads T-30002 | 404 | 200 | Who is the caller? Tomás has no membership at all. |
| 5. Ines’s similar tickets | Bramble’s only | 2 of 5 are Larkfield’s | In which organization? The search has no filter. |
The expected answers come straight from the access matrix. Use the explorer to check them. It starts where attempt 3 failed.
Explore
What the API should have answered
Predict first: Omar is read_only at Larkfield. What should the API answer when he adds a message? And when Sam, staff at Larkfield, reads a Bramble Books ticket?
- Role in this organization
- read_only
- The API answers
- 403 role_cannot
- The rule
- read_only may not ticket.write
Omar is read_only at Larkfield, and the rule says "read_only may not ticket.write". The API answers 403. A member can already see the organization's tickets, so a 403 reveals nothing new.
- 200: allowed
- 403: a member, but the role may not
- 404: not a member, nothing shown
| Action | Larkfield read_only | Bramble Books no membership |
|---|---|---|
ticket.readlist and read tickets, find similar tickets | 200 yes | 404 |
ticket.writeadd a message to a ticket | 403 (chosen) | 404 |
file.readget a download link for an attachment | 200 yes | 404 |
file.uploadupload an attachment | 403 | 404 |
document.readsearch the public help documents | 200 yes | 404 |
document.read_staffalso the staff-only documents | 403 | 404 |
member.readlist the members of the organization | 403 | 404 |
member.manageinvite, change a role, remove a member | 403 | 404 |
export.createstart a background export of tickets | 403 | 404 |
audit.readread the organization's audit events | 403 | 404 |
Every answer comes from the project's decide() in ticket_api/access.py, printed by python -m scripts.access_matrix --json. "200" means allowed: a request that creates a record, such as a new message, answers 201. The people are made up.
Text description of the explorer
The explorer has three groups of buttons: a person, an organization and an action. It shows the person’s role there, the answer of the access matrix (200, 403 or 404) and the rule. A table shows the person’s whole row in both organizations.
It starts at Omar, Larkfield, ticket.write: Omar is read_only, the rule says “read_only may not ticket.write”, and the answer is 403. Today’s API answered 201. Sam at Bramble Books has no membership, so ticket.read answers 404; today’s API answered 200. Tomás gets 404 everywhere. Camille gets 200 for ticket.read at Bramble Books, but 403 for ticket.write there.
What fixes each failure
Section titled “What fixes each failure”Kwame maps each failure to the work ahead:
| Missing piece | Fixes attempts | Where in the course |
|---|---|---|
| Know the person: a signed-in user with a checked token, not a shared key | 4, and all others need it | Module 2 and Module 3 |
Check membership and role on the server for every route, under /v1/tenants/{tenant}/… |
1, 3, 4 | Authorization in the API |
| Make the organization a required part of every query, search and file link | 1, 2, 5 | Tenant-safe queries and File and retrieval access |
| Test every person against every route in both shops | all, for good | Negative testing |
Tomás has one more shortcut: “Let the browser send a header, X-Tenant-ID: bramble, and filter by it.” Think it through, without code. Sam changes one header, and the API shows him Bramble’s tickets. The header answers “which organization does the caller want”, not “which organization may they see”. The API must answer the second question from its own membership table. The course end’s attempts send exactly this forged header, and the API refuses it.
These attempts are worth running from the first day of any shared application, before real customers arrive. They cost little: one script and a few seconds. The real cost is in the fixes, which change every route and every query. That is why the course spends three modules on them.
Common mistakes
Section titled “Common mistakes”Common mistake
Each run adds another message
Symptom: every run of python -m scripts.attempts adds a message to ticket T-30002. After python -m scripts.load --reset and two runs, it has three messages instead of one. Check in psql:
SELECT message_id, author, body FROM messages WHERE ticket_id = 'T-30002' ORDER BY message_id; message_id | author | body------------+--------+--------------------------------------------------- 2 | agent | Could you send the receipt or a screenshot of … 390 | agent | (attempt: a read-only member writes) 391 | agent | (attempt: a read-only member writes)(3 rows)The first message is shortened here.
Cause: attempt 3 succeeds today, so every run really writes a message. Your message IDs can differ.
Fix: run python -m scripts.load --reset. T-30002 has one message again. After Module 4, the API refuses the attempt and writes nothing.
Reset. python -m scripts.load --reset removes every message that you added in this lesson. If your project is broken, copy the snapshot end-of-m01 again, as in Users and organizations.
Knowledge check
Section titled “Knowledge check”Check your understanding
Explain the difference between an object-level access failure (BOLA) and privilege escalation. Give one example of each from this lesson.
Show the answer
An object-level access failure lets a caller reach a record that they may not reach. Sam reads Bramble’s ticket T-40001 by guessing its ID. Privilege escalation lets a caller do an action that their role does not allow. Omar, read-only, adds a message to T-30002. The first one fails “in which organization?” for one record. The second one fails “what may they do?”.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Predict: Larkfield changes every ticket ID to a random UUID, and changes nothing else. Does attempt 1 still succeed? Why or why not?
Show the answer
It still succeeds for any ID that Sam learns, because the API still checks only the key. Random IDs make guessing harder, but IDs leak through links, logs, screenshots and other answers, as the attachment ID in T-40001’s answer shows. The fix is a check that the record belongs to an organization where the caller is a member.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Module check: Camille is staff at Larkfield and read-only at Bramble Books. For each request, give the answer of the access matrix (200, 403 or 404) and the reason: (a) read Bramble's ticket T-40001 through Bramble's organization; (b) add a message to T-40001 there; (c) add a message to Larkfield's T-30002; (d) invite a new member to Larkfield; (e) read T-40001 through Larkfield's organization.
Show the answer
(a) 200: at Bramble Books, Camille is read_only, and read_only may ticket.read.
(b) 403: read_only may not ticket.write. Camille is a member and already sees the ticket, so a 403 reveals nothing new.
(c) 200, and the API answers 201 for the new message: at Larkfield, Camille is staff, and staff may ticket.write.
(d) 403: staff may not member.manage. Only Larkfield’s owner, Grace, may.
(e) 404: T-40001 is not a Larkfield ticket. The lookup is inside Larkfield, so the ticket “does not exist” there, even though Camille may read it through Bramble’s organization.
One person gets five different answers, because each decision uses the role of one membership and the organization of the record.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Practical check: run the attempts. What is the last line, and which attempt changes the database?
Show the answer
python -m scripts.attempts ends with 5 of 5 attempts got what they should not get. Attempt 3 (Omar’s message) writes a row in messages. python -m scripts.load --reset removes it.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Recap
- An object-level access failure (BOLA): the API returns a record because the caller knows its ID. Sam read Bramble’s T-40001. Random IDs hide records; only a check protects them.
- Privilege escalation: a person does more than their role allows. Omar, read-only, added a message. A hidden button is not a protection: the rule must be in the API.
- Without an organization, lists, searches and old bugs mix the shops. 77 of 100 listed tickets were Larkfield’s, and the injection demo now returns 52 customers.
python -m scripts.attempts: 5 of 5 attempts got what they should not. At the end of the course, the same attempts and five more all fail.
Module recap. You can now ask the three questions of any request. You can store users, organizations, memberships and roles, and read the access matrix. And you can explain why Camille gets different answers in two organizations. Your project matches end-of-m01.
Next, the API learns who the caller is. You meet OAuth and OpenID Connect, and the identity provider that signs people in.
Next: OAuth and OIDC