Skip to content

Threat examples

  • Lesson 3
  • 50 min
  • Module 1: Identity and permission basics

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 readyAbout 50 minutes

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.
  • curl and the API’s routes /v1/tickets/{id}, …/messages and …/similar, from the API and databases courses.

Your project

  • ~/projects/ticket-api at the snapshot end-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.

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.

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.

  1. Start the API with the key, in a second terminal with the virtual environment active:

    Terminal window
    API_KEY=local-dev-key fastapi dev

    On Windows, set $env:API_KEY = "local-dev-key" first, then run fastapi dev.

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

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

Sam, staff at Larkfield, sends GET /v1/tickets/T-40001 with X-API-Key: local-dev-key. ticket-api checks whether the key is right: yes. The query SELECT … FROM tickets WHERE ticket_id = 'T-40001' finds Bramble Books' ticket, Charged twice for order BB-310729, and the API sends 200 OK with the whole ticket back to Sam. A dashed red box above the API says what nobody asked: is T-40001 in an organization where Sam is a member?
The API checks the key and then looks up the ID. Nothing asks whether the ticket belongs to an organization where Sam is a member.
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.

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:

Terminal window
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.

Two paths. Through the page: Omar's page, read-only, shows ticket T-30002 with no Reply button, so no request is sent. Around the page: Omar's terminal sends curl -X POST …/T-30002/messages; ticket-api checks whether the key is right, yes; 201 Created, the message is saved. A line says: the button lives in the browser, the rule must live in the API.
A hidden button stops only the people who use the page. A request that goes around the page meets only the API's checks.
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.

Some failures need no guess at all. Ines owns Bramble Books. She lists the tickets “of her shop”:

Terminal window
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-310461
T-30200 C-0025 wooden garden bench stopped working
T-30199 C-0027 Change delivery address
T-30198 C-0038 Merge two accounts
T-40039 B-20110 Where is my order BB-310304?
T-30197 C-0029 Payment failed but money taken

Each 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:

Terminal window
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.

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.

Terminal window
python -m scripts.attempts
Made-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-30200
3. 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

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
Omar: every action in both organizations
ActionLarkfield
read_only
Bramble Books
no membership
ticket.readlist and read tickets, find similar tickets200 yes404
ticket.writeadd a message to a ticket403 (chosen)404
file.readget a download link for an attachment200 yes404
file.uploadupload an attachment403404
document.readsearch the public help documents200 yes404
document.read_staffalso the staff-only documents403404
member.readlist the members of the organization403404
member.manageinvite, change a role, remove a member403404
export.createstart a background export of tickets403404
audit.readread the organization's audit events403404

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.

The access matrix of the project (decide() in ticket_api/access.py) for every person, organization and action. Today's API answers 200 or 201 to all five attempts.
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.

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

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?”.

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.

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.

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.

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