OWASP Top 10: Broken Access Control and Injection Explained
CybersecurityLesson 1 of 45 min read
What you'll learn
- Recognise broken access control, including insecure direct object references
- Explain how injection happens when data and commands are mixed
- Fix injection with parameterised queries and fix access control in the query itself
- Know where output encoding and input validation fit alongside both fixes
On this page
A parcel-locker kiosk lets residents collect packages with a code. Its mobile app calls GET /api/parcels/2 to show one parcel. The developer tested with parcel 1, which belonged to the logged-in tester, and it worked. Nobody tried parcel 2, which belongs to a neighbour. That single untested number is the most common serious web flaw, and the same app has a second flaw in how it searches by owner name. Both are standard themes in the OWASP Top 10 (current edition), and both are fixable with small, boring changes.
Broken access control: logged in is not the same as allowed
Authentication answers "who are you?". Authorisation, or access control, answers "are you allowed to do this to this particular thing?". Broken access control means the second question is skipped or answered on the client. It appears as reading or editing another user's records by changing an id in a URL (called an insecure direct object reference, or IDOR), reaching admin functions by guessing their path, performing an action with the wrong HTTP method, or trusting a role flag sent from the browser.
The rule is simple to state: every request must be checked on the server, against the identity the server knows, for that specific resource. Hiding a button in the interface does not count, nor does an unguessable-looking id. Here are both versions of the parcel lookup.
import sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE parcels (id INTEGER PRIMARY KEY, owner TEXT, locker TEXT);
INSERT INTO parcels (id, owner, locker) VALUES (1, 'asha', 'A-07'), (2, 'bruno', 'B-02');
""")
def get_parcel_vulnerable(current_user: str, parcel_id: int):
# Authenticated, but never checks WHOSE parcel it is.
return db.execute("SELECT id, owner, locker FROM parcels WHERE id = ?", (parcel_id,)).fetchone()
def get_parcel_fixed(current_user: str, parcel_id: int):
# Ownership is part of the query, so another user's id simply finds nothing.
return db.execute(
"SELECT id, owner, locker FROM parcels WHERE id = ? AND owner = ?",
(parcel_id, current_user),
).fetchone()
print("vulnerable:", get_parcel_vulnerable("asha", 2))
print("fixed (own): ", get_parcel_fixed("asha", 1))
print("fixed (other):", get_parcel_fixed("asha", 2))Output
vulnerable: (2, 'bruno', 'B-02') fixed (own): (1, 'asha', 'A-07') fixed (other): None
The vulnerable version asked the user "are you logged in?" somewhere upstream and then happily returned bruno's parcel to asha. The fix puts the ownership condition inside the query. This pattern is strong because there is no separate "check" step to forget on a new endpoint. In a real API, return the same not-found response for "does not exist" and "not yours" so the response does not confirm which ids exist.
Injection: when data is parsed as commands
Injection happens whenever untrusted input is concatenated into something that an interpreter later parses: an SQL statement, an operating-system command, an LDAP filter, a template, an HTML page. The interpreter cannot tell where your intended instructions end and the user's data begins. SQL injection is the textbook case, so that is what the demo shows. It uses the same table idea as above, with the kiosk's owner search.
import sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE parcels (id INTEGER PRIMARY KEY, owner TEXT, locker TEXT, pickup_code TEXT);
INSERT INTO parcels (owner, locker, pickup_code) VALUES
('asha', 'A-07', '4481'),
('bruno', 'B-02', '9027'),
('chen', 'C-11', '1536');
""")
def find_parcels_vulnerable(owner: str):
# BAD: user input is pasted into the SQL text.
sql = f"SELECT locker, pickup_code FROM parcels WHERE owner = '{owner}'"
return db.execute(sql).fetchall()
def find_parcels_safe(owner: str):
# GOOD: the value travels separately from the SQL text.
return db.execute(
"SELECT locker, pickup_code FROM parcels WHERE owner = ?", (owner,)
).fetchall()
print("normal input, vulnerable:", find_parcels_vulnerable("asha"))
print("normal input, safe: ", find_parcels_safe("asha"))
hostile = "x' OR '1'='1"
print("hostile input, vulnerable:", find_parcels_vulnerable(hostile))
print("hostile input, safe: ", find_parcels_safe(hostile))Output
normal input, vulnerable: [('A-07', '4481')]
normal input, safe: [('A-07', '4481')]
hostile input, vulnerable: [('A-07', '4481'), ('B-02', '9027'), ('C-11', '1536')]
hostile input, safe: []With ordinary input both functions agree, which is exactly why this bug survives testing. With input that contains a quote, the vulnerable version rewrites its own WHERE clause and returns every row. The safe version treats the whole string as a value to compare, finds no owner with that strange name and returns nothing. The only difference between the two functions is ? and a separate tuple. Every mainstream database driver and ORM supports this; the placeholder syntax varies (?, %s, $1, :name).
Related injection themes
| Interpreter | Typical flawed pattern | Fix |
|---|---|---|
| SQL | String-building queries with input | Parameterised queries or ORM query builders |
| OS shell | Passing input to a shell string | Call the program with an argument list, no shell; validate against an allow-list |
| HTML (cross-site scripting) | Writing user text into a page unescaped | Context-aware output encoding; frameworks that escape by default; a Content-Security-Policy |
| Templates / expression engines | Rendering user input as the template itself | Pass user input only as data, never as template source |
Input validation (rejecting a parcel id that is not a positive integer) is a useful extra layer, but it is not the primary defence: names such as O'Brien are legitimate and must work. The primary defence is to keep data and commands apart, and for output to a browser, to encode for the context where the data lands.
Common mistakes
- Checking authorisation only in the UI or on list endpoints. Symptom: the list page shows only your items, but
GET /items/<other id>returns someone else's. Fix: check ownership on every endpoint that takes an id. - Escaping quotes by hand. Symptom: it works until input with a different encoding or character set arrives, then the bug reappears. Fix: parameterised queries.
- Parameterising the value and concatenating the rest. Symptom: a sort or search-field option that accepts a raw column name from the request. Fix: allow-list mapping.
- Trusting a role from the request body. Symptom: a normal user sends
{"role": "admin"}and the profile update accepts it. Fix: never bind role fields from client input; set them server-side. - Different error messages for "not found" and "forbidden". Symptom: anyone can map which ids exist. Fix: return one response for both where hiding existence matters.
How this shows up in real projects and interviews
Access control bugs usually survive because tests are written from the happy path of a single user. Real teams catch them with a second test user and a table of who should and should not reach what (you will build that in the last lesson). In interviews, "what is SQL injection and how do you prevent it?" is a staple; a convincing answer names parameterised queries, explains why escaping and validation alone are weaker, and mentions least-privilege database accounts so a successful injection can do less damage. If you want the SQL fundamentals underneath these examples, see SQL fundamentals.
Try it yourself
Add a column called status to the parcels table and write a function that lets a user mark only their own parcel as collected. Make it return True only when exactly one row changed, and prove with two users that the second one cannot change the first one's parcel.
Show a hint
Use UPDATE ... WHERE id = ? AND owner = ? and check cursor.rowcount == 1, which tells you whether any row actually matched both conditions.
Key takeaways
- Broken access control is a missing or client-side check on a specific resource; put ownership into the server-side query.
- Injection comes from mixing untrusted data into a command that an interpreter later parses.
- Parameterised queries separate values from SQL text; they do not cover identifiers, which need an allow-list.
- Validation helps, but encoding and parameterisation are the real defences.
- Use two test users to prove that user A cannot reach user B's data.
Test yourself
A trick from this lesson's ride: Logged in answers 'who are you'; allowed answers 'is this record yours'. Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
What is broken access control in the OWASP Top 10?
It means users can act outside their intended permissions, for example reading another customer's order by changing an id in the URL. It happens when authorisation is missing, done only in the client, or applied inconsistently across endpoints.
How do parameterised queries prevent SQL injection?
The database receives the SQL text and the values separately, so input is always treated as a value and never as part of the command. A quote character in the input is just another character to compare.
Is input validation enough to stop injection?
No. Validation reduces risk, but legitimate input can contain special characters and rules can have gaps. Rely on parameterised queries and output encoding as the primary defence, and treat validation as an additional layer.