Security Labs · Lab 2

Broken Auth & Access Control

Authentication asks “who are you?” Access control asks “are you allowed to touch this?” When either check is missing or trusts the wrong thing, one user can reach another user’s data. This lab teaches you to spot the missing check, add it, and test for it.

Security Labs OWASP A01 & A07 — Lab 2 of 3 ~30 min read · ~70 min with exercises

1 The Hook

Aroha Health, a fictional NZ primary care provider, launched a patient portal. After logging in, a patient could view their own lab results at a web address that ended in their record number — something like /results/view?id=80412. It worked perfectly. Each patient saw their own results.

A tester changed one thing. They were logged in as themselves, but they edited the number in the address bar from 80412 to 80411 and pressed enter. Someone else’s lab results loaded on the screen. Another patient’s name, another patient’s blood tests. The tester had not broken in — they had simply asked for a record that was not theirs, and the system handed it over.

The login worked. The patient was who they said they were. What was missing was the second check: is this patient allowed to see this particular record? The code looked up record 80411 and returned it without ever asking whether it belonged to the logged-in user. That is broken access control, and this specific form — changing an ID in a request to reach someone else’s data — is called an Insecure Direct Object Reference, or IDOR.

In production this would have been a serious health-information breach under the Privacy Act 2020, discoverable by anyone who could count. It needed no tools and no skill, just a willingness to change a number. OWASP ranks Broken Access Control as A01 — the number-one risk on the web — precisely because flaws this simple are this common.

The lesson: authentication is not authorisation. Proving who you are does not prove you are allowed. You find these flaws by logging in as one user and trying to reach another user’s things.

2 The Rule

Authentication proves who you are; authorisation proves you are allowed. Every request that touches a specific record must check, on the server, that the logged-in user owns or is permitted that record — an ID in a URL or request body is a claim, never a permission. If you can change an identifier and reach data that is not yours, the access-control check is missing.

3 The Analogy

Analogy

A hotel where your room key opens every door because reception only checked you are a guest.

Picture a hotel on the Wellington waterfront. At check-in, reception confirms you are a real, paying guest and hands you a key — that is authentication, and it works fine. But the key turns out to open every room in the building, not just yours, because the lock only checks “is this a valid guest key?” and never “is this the key for this room?” You are who you say you are, and you can still walk into anyone’s room. That is broken access control: a valid identity with no per-resource permission check.

The fix is a lock that checks both: a valid guest and the right room. In code, that is a server-side check that the logged-in user actually owns the record they are asking for — performed every time, on every request.

4 Authentication vs Authorisation

These two words sound alike and are constantly confused, but they are different checks at different moments, and most access-control bugs come from doing one and forgetting the other.

  • Authentication (AuthN) — “who are you?” Establishes identity, usually once, at login. RealMe sign-in, a password, a session token. Covered by OWASP A07: Identification and Authentication Failures when it is weak or bypassable.
  • Authorisation (AuthZ) — “are you allowed to do this, to this thing?” Checked on every request that touches a protected resource. Covered by OWASP A01: Broken Access Control when it is missing or wrong.

The Aroha Health flaw was authentication working and authorisation missing. The patient was correctly identified, but no check confirmed they were allowed to read record 80411. The two failures also combine: weak authentication lets an attacker become a valid user, and missing authorisation then lets that user reach everything.

Pro tip: A fast way to find these as a tester: log in as a low-privilege user, then try to do something only a higher-privilege user should do, and separately try to reach another user’s records by changing an identifier. If either works, you have found A01.

5 Spotting IDOR — the Missing Ownership Check

IDOR is the most common form of broken access control and the easiest to read for. Here is the Aroha Health results handler, written so the missing check is visible:

# View lab result — VULNERABLE
@app.get("/results/view")
def view_result(request):
    require_login(request)  # authentication only
    result_id = request.args["id"]
    result = db.get_result(result_id)
    return render(result)

The flaw is what is not there. require_login confirms the user is logged in — authentication — but nothing checks that result_id belongs to that user. The handler fetches whatever ID is in the request and returns it. Change the id, get someone else’s record.

The tell-tale signs you read for:

  • A record fetched by an ID that came from the request — URL, query string, path, or body.
  • A login check but no ownership checkrequire_login with nothing comparing the record’s owner to the current user.
  • Sequential or guessable IDs — numbers that count up make the flaw trivial to abuse, though UUIDs do not fix it; obscurity is not access control.

6 Broken Authentication and Missing Role Checks

Broken access control has siblings. Once you can read for the missing check, you can spot these too.

Missing function-level authorisation

An admin-only endpoint — say /admin/refund on a fictional BNZ-style portal — that checks the user is logged in but never checks they are an admin. Any authenticated customer who knows the URL can issue refunds. The UI hides the button; the server forgot to enforce the role. Hiding a control is not protecting it.

Trusting a client-supplied role

A request that includes role=admin in the body or a cookie, and the server believes it. The client controls that value, so any user can claim any role. Role must come from the server’s own record of the session, never from the request.

Weak authentication (A07)

Session tokens that never expire, predictable tokens, no lockout after repeated failed logins, or password reset that does not verify the requester. These let an attacker become a valid user in the first place — and then any missing authorisation check does the rest.

Pro tip: The NZISM and the Government Algorithm Charter both expect access to personal information to be controlled and auditable. A missing ownership check on a Te Whatu Ora or MSD record is not just a bug — it is a reportable privacy breach. Testing for A01 directly addresses an NZ compliance obligation.

7 The Fix: Check Ownership on the Server

The fix for IDOR is to verify, on the server, that the logged-in user is permitted the specific record — on every request, every time. Here is the Aroha Health handler, fixed:

# View lab result — FIXED
@app.get("/results/view")
def view_result(request):
    user = require_login(request)  # authentication
    result_id = request.args["id"]
    result = db.get_result(result_id)
    if result is None or result.patient_id != user.patient_id:
        return forbidden()  # authorisation: ownership check
    return render(result)

One block changed everything: after fetching the record, the code compares the record’s owner (result.patient_id) to the logged-in user and returns forbidden if they do not match. Now changing the id to someone else’s record returns a 403, not their data.

The rules that make a fix correct:

  • Check on the server, every request — never rely on the UI hiding a button or a front-end guard.
  • Derive identity from the session, not the requestuser comes from the verified session; the role and ownership are decided server-side.
  • Return the same response for “not found” and “not yours” where it matters, so an attacker cannot tell which IDs exist.
  • Enforce roles at the function level too — an admin endpoint checks the admin role on the server, independent of the menu.

8 Building Access-Control Test Cases

Access-control testing needs at least two accounts — you log in as one user and try to reach another’s things. The test case names both users, the resource, and what must be denied.

Test ID: SEC-AC-004
Vulnerability: IDOR / broken access control (OWASP A01)
Endpoint: GET /results/view?id={id}
Setup: User A owns result 80412. User B owns result 80411.
Steps: Log in as User A. Request /results/view?id=80411 (User B's).
Expected result: 403 Forbidden. No record content returned.
Must NOT happen: User B's result is shown; any field of it leaks in the
                  response or error.
Negative control: As User A, request own result 80412 — must succeed (200).
Evidence: Request/response capture for both calls.
Traceability: Risk R-03 (cross-patient data exposure / Privacy Act).
Result: [Pass / Fail]

A complete access-control suite also covers: function-level checks (a non-admin hitting an admin endpoint must get 403), client-supplied role tampering (sending role=admin must be ignored), and the unauthenticated case (no session must get 401, not data). The negative control — User A reaching their own record — matters as much here as in injection testing, because a clumsy fix that denies everyone “passes” the attack test while breaking the application.

9 Common Mistakes

🚫 Confusing authentication with authorisation

Why it happens: “The user is logged in” feels like enough, and a require_login call looks like a security check.
The fix: Logging in proves identity, not permission. Every request that touches a specific record needs a separate ownership or role check on the server. Test by logging in as one user and trying to reach another’s data.

🚫 Trusting that hiding the UI control protects the function

Why it happens: If the button is not on the screen, it feels unreachable.
The fix: Anyone can call the endpoint directly with a tool or by editing a request. The server must enforce the role and ownership itself, regardless of what the front end shows. Test the endpoint, not just the page.

🚫 Believing UUIDs or random IDs make IDOR impossible

Why it happens: Unguessable IDs feel like they hide the records.
The fix: Obscurity is not access control. Unguessable IDs leak through referrals, logs, shared links, and other endpoints — and the missing ownership check is still there. The fix is always the server-side ownership check, not harder-to-guess IDs.

🚫 Trusting a role sent by the client

Why it happens: Reading role from the request body or a cookie is convenient.
The fix: The client controls anything it sends, so a user can claim role=admin. The role must come from the server’s own session record. Test by tampering with the role field and confirming the server ignores it.

10 Now You Try

Three graded exercises: spot the missing check, fix the code, then design the tests. Write your answer, run it for AI feedback, then compare to the model answer. Every snippet here is a safe teaching example — you are identifying and repairing flaws, never attacking a real system.

🔍 Exercise 1 of 3 — Spot the Vulnerability

Read the endpoint below from a fictional NZ tax-agent portal. It returns a client’s details by IRD number. Identify the vulnerability, name the missing check, and explain how an attacker would abuse it.

# Tax-agent portal — client lookup
@app.get("/client/details")
def client_details(request):
    require_login(request)
    ird = request.args["ird"]
    client = db.get_client_by_ird(ird)
    return json(client)

Name the vulnerability, the missing check, and the exploit:

Show model answer
Vulnerability: Broken access control — Insecure Direct Object Reference (IDOR), OWASP A01.

The missing check: there is authentication (require_login) but NO authorisation. Nothing confirms the logged-in tax agent is actually permitted to view the client identified by `ird`. The handler fetches whatever IRD number is supplied and returns it.

How an attacker abuses it: a logged-in agent (or any authenticated user) changes the `ird` query parameter to an IRD number that is not one of their own clients — e.g. iterating through IRD numbers — and the portal returns that person's tax details. This is mass exposure of personal and tax information, a serious Privacy Act 2020 breach. Sequential or known IRD numbers make it trivial; unguessable IDs would not fix it because the ownership check is still missing.

The fix (Exercise 2): after fetching the client, confirm on the server that the client belongs to this agent, and return 403 if not. require_login alone is authentication, not authorisation.
🔧 Exercise 2 of 3 — Fix the Code

Rewrite the handler below so the access-control flaw is closed. It is a fictional KiwiSaver provider endpoint that returns an account statement by account ID. Add a server-side ownership check so a user can only see their own account.

Vulnerable original:
@app.get("/statement")
def statement(request):
    require_login(request)
    account_id = request.args["account"]
    return json(db.get_statement(account_id))

Write the fixed handler:

Show model answer
@app.get("/statement")
def statement(request):
    user = require_login(request)
    account_id = request.args["account"]
    account = db.get_account(account_id)
    if account is None or account.owner_id != user.id:
        return forbidden()          # 403 — ownership check
    return json(db.get_statement(account_id))

What makes this correct: identity comes from the verified session (user = require_login(...)), the record's owner is compared to that user on the SERVER, and a non-matching request returns 403 instead of data. Changing the `account` parameter to someone else's account now fails.

Also acceptable and arguably better: scope the query to the user so the other account is never even fetched — e.g. db.get_statement_for(user.id, account_id), returning None if it is not theirs. Returning the same response for "not found" and "not yours" avoids leaking which accounts exist. A fix that trusts a role or owner value from the request, or that only hides the field in the UI, is NOT correct.
🏗️ Exercise 3 of 3 — Design the Test Cases

Design 4 security test cases for a fictional MSD client portal that lets a case manager view client records, to prove it enforces access control. Cover IDOR, a function-level/role check, client-supplied role tampering, and the unauthenticated case. Each case needs: an ID, the setup/steps, the expected result, and what must NOT happen. Include a negative-control case.

Show model answer
SEC-AC-01 (IDOR) | Setup/steps: Case manager A is assigned client 5001; client 5002 belongs to a different team. Log in as A, request /client?id=5002 | Expected: 403 Forbidden, no record content | Must NOT happen: client 5002's details are returned in the response or error

SEC-AC-02 (role/function-level) | Setup/steps: Log in as an ordinary case manager (not a supervisor). Call the supervisor-only endpoint POST /client/close-case directly | Expected: 403 Forbidden | Must NOT happen: the case is closed; the action succeeds because the UI button was merely hidden

SEC-AC-03 (role tampering) | Setup/steps: As an ordinary case manager, send a request that adds role=admin in the body or cookie | Expected: the server ignores the supplied role and authorises from the session only; request handled as ordinary user | Must NOT happen: the client-supplied role grants elevated access

SEC-AC-04 (negative control) | Setup/steps: As case manager A, request /client?id=5001 (their own assigned client) | Expected: 200, record shown normally | Must NOT happen: a clumsy fix denies legitimate access — proves the control did not break the app

Bonus: add an unauthenticated case (no session → 401, not data), capture request/response evidence, and trace each case to a numbered risk (e.g. R-03 cross-client data exposure / Privacy Act). Weak suites test only one IDOR and omit the negative control or the role-tampering case — those omissions are the difference being marked.

11 Self-Check

Click each question to reveal the answer.

Q1: In one sentence, what is the difference between authentication and authorisation?

Authentication proves who you are (identity, usually checked once at login); authorisation proves you are allowed to do this, to this specific thing (permission, checked on every request that touches a protected resource). Most access-control bugs are authentication present, authorisation missing.

Q2: What is IDOR, and how do you test for it?

An Insecure Direct Object Reference — when changing an identifier in a request (a URL id, a body field) reaches another user’s data because the server never checks ownership. You test it by logging in as one user and requesting a record that belongs to another user; if it returns their data instead of 403, the check is missing.

Q3: Why is hiding a button in the UI not access control?

Because anyone can call the endpoint directly with a tool or by editing a request — the front end is fully under the user’s control. The server must enforce the role and ownership check itself, on every request, regardless of what the UI displays. Test the endpoint directly, not just the page.

Q4: Why do unguessable IDs (UUIDs) not fix IDOR?

Because obscurity is not access control. The ownership check is still missing — the unguessable ID just makes it slightly harder to find a target, but the ID leaks through referrers, logs, shared links, and other endpoints. The real fix is the server-side ownership check, not a harder-to-guess identifier.

Q5: Why must the user’s role come from the session and not the request?

Because the client controls everything it sends — a body field, a cookie, a header — so a user could simply claim role=admin. The server must decide the role from its own verified session record. Test by tampering with a client-supplied role and confirming the server ignores it.

12 Interview Prep

Real questions asked in NZ QA interviews for security-aware testing roles. Read the model answers, then practise your own version.

“Explain IDOR to me and how you would test for it.”

IDOR is an Insecure Direct Object Reference — broken access control where changing an identifier in a request reaches data that is not yours, because the server checks you are logged in but never checks you own the record. I’d test it with two accounts: log in as User A, note the ID of one of A’s records, then request a record ID that belongs to User B. If B’s data comes back instead of a 403, the ownership check is missing. I’d try it in URLs, query strings, path segments, and JSON bodies, and I’d add a negative control — A reaching A’s own record — to prove the fix did not just deny everyone. In an NZ context that flaw on a health or benefits record is a Privacy Act breach, so I’d trace it to a risk and capture the requests as evidence.

“A feature is admin-only and the button only shows for admins. Is that secure?”

No — hiding the button is a UI convenience, not a security control. Anyone can call the endpoint directly with a tool or by editing a request, so if the server does not check the admin role itself, an ordinary authenticated user can trigger the admin action. I’d test it by logging in as a non-admin and calling the admin endpoint directly, expecting a 403. I’d also send a tampered role=admin in the request to confirm the server authorises from the session, not from anything the client supplies. The rule is that every protected function enforces its role check server-side, independent of the menu.

“What authentication weaknesses would you check on an NZ services portal?”

Under OWASP A07 I’d check that sessions expire and are invalidated on logout, that tokens are unpredictable and not reusable, and that there is lockout or rate-limiting after repeated failed logins so credential stuffing is not free. I’d test password reset to confirm it actually verifies the requester and cannot be used to take over an account, and I’d check that multi-factor or RealMe sign-in cannot be skipped. The reason it matters is that weak authentication lets an attacker become a valid user, and then any missing authorisation check — the A01 flaws — lets that user reach everyone’s data. The two are tested together.