Most access-control bugs are described as a missing check. This room shows something more precise: a check that runs, on a value the caller supplied, and answers a question about the wrong object. The session is valid, the endpoint is permitted, and the record that comes back belongs to somebody else.
That is why I paired this companion with today's field note on authority provenance, the four places an agent's permission actually comes from. The room is the hand-worked version of one of those four: a reference arriving in the request, and being trusted as though it were a decision.
01 · Scope and authorisation
This companion documents the public TryHackMe IDOR room, a standard beginner web room. I worked through it in my own session, everything below stays inside that environment, and I have published no flags, no quiz answers, no credentials, no target addresses and no copy-and-paste payloads. Where the room wants you to manipulate a value, I describe the reasoning that identifies the value so you make your own change rather than replaying mine.
The room moves from the concept of an insecure direct object reference through the forms a reference can take, then to where references live in a request, and finishes with a practical case against an API route. It maps to CWE-639, authorisation bypass through a user-controlled key, with CWE-284, improper access control. On the ATT&CK side the relevant technique is valid accounts, because nothing about the request is invalid except the authority behind it.
02 · Learning objectives
- State the difference between authentication and authorisation precisely enough to say which one an IDOR breaks.
- Recognise the four forms a reference takes, and why three of them are obfuscation rather than protection.
- Enumerate the locations a reference can appear in, rather than only the one a rendered page shows you.
- Reason about the object the server actually loaded, not the object the response suggests.
- Distinguish a fix applied to the page from a fix applied to the endpoint that serves the same object.
- Write up an access-control finding with a stated exposure rather than the word critical.
03 · Prerequisites and the loop I use
The room assumes a browser, a proxy for inspecting and altering requests, and enough HTTP fluency to tell a parameter from a path segment. The loop I applied is the one I use on any application:
- Observe what the request actually sends, at the level of the parameter and its value, not what the interface implies it sends.
- Hypothesise which object the server will load if that value changes, and who it will believe you are while loading it.
- Choose the least invasive change that would falsify the hypothesis, and change one thing at a time.
- Interpret the response as a property of the authorisation model rather than a property of the page.
- Update the model and decide whether the finding is the missing decision, the reachable route, or both, because those have different owners and different fixes.
Observation dominates because the interesting fact in this room is not what the application refuses. It is what it returns when the only thing that changed was a number the caller controls.
04 · The model: a reference is a request, not a decision
Hold this sentence apart from everything else, because the entire room follows from it. A reference the caller supplies is a request for access. It is not a decision that access is permitted. When an application treats the first as the second, the object becomes reachable by anyone who can guess, decode, or observe the value.
Compare it with yesterday's companion in this series. That room was authentication: the question is who the caller is, and the failure is that the question was never asked or answered wrongly. This room is authorisation: the caller is identified correctly, the session is legitimate, and the failure is that the object was chosen by the wrong party. Getting that distinction right changes the fix completely, and it is the first thing to establish before writing a finding.
A useful way to hold the two together: authentication decides whether you get in the building. Authorisation decides which rooms are yours once you are inside. An insecure direct object reference is a valid keycard opening a door that was never assigned to it.
05 · Stage one: the four forms a reference takes
The room walks through four forms, and the progression is the lesson. Each one adds effort for the person guessing and adds nothing at all for the person attacking, which is why the distinction matters commercially as well as technically.
The plain form. The reference is a literal number in the request, and changing it changes the object. This is the case everyone finds first, and it is the case a test written as a checklist will catch.
The encoded form. The value is wrapped in an encoding, most often base64, so it no longer looks like a number. Decoding it returns the same literal value underneath. The encoding changed the appearance of the reference and nothing about the authorisation decision, which is the first place a reader should notice that obfuscation and access control are different mechanisms.
The hashed form. The value is a digest rather than an integer. It looks less guessable, and it behaves identically: one input value maps to one object, every time, and the mapping is stable. A digest is a way of naming an object, not a way of restricting who may read it. Anyone who ever sees the digest for an object can ask for that object.
The unpredictable form. The reference is something like a GUID, and this is the only form where guessing genuinely costs effort. It is worth being precise about what that buys, because overstating it is common. An unguessable identifier is a secrecy control. It does not restrict access, it restricts discovery, and it fails the moment the value leaks through a log, a share, a referrer, an API response, or a colleague who pastes it into a ticket. The authorisation question is still unanswered, it has simply been deferred.
The observation to record from this stage is a column, not a conclusion: for each reference you find, its form, and whether changing it changes the object. That column is the finding.
06 · Stage two: the location decides the request you inspect
The room's next step is where references actually live, and this is where most incomplete testing happens. The rendered page shows one or two of them. The application serves several.
- Query parameters on a page route, which is the case every tester checks and the case every fix addresses.
- API routes that return the same object without the view, very often through a different handler with its own authorisation code or its own absence of it.
- Path segments, where the object is part of the path rather than the query string, and where a fix written against the query-string form does not apply.
- Body fields on write requests, which are frequently overlooked because the read path was reviewed and the write path was not.
The reason this matters more than it looks: authorisation logic tends to be attached to a route rather than to the object. When the same object is reachable through two routes, the protection is only as good as the weaker route, and the fix applied to the stronger one produces a report that reads as closed while the object stays reachable.
That is the habit worth taking from this stage. For every object you can see, ask which routes return it, and test the one that looks least like the interface you used to find it.
07 · Stage three: the practical case, and what the server actually loaded
The room finishes with a practical case against an API route that takes a customer identifier and returns that customer's record. The interesting part is not that the identifier can be changed. It is what the application does with it.
The handler receives the value, loads the record it names, and returns it. Nowhere in that path does anything compare the loaded record against the principal making the request. The session was checked, because sessions are checked centrally by middleware that everybody remembers to configure. The ownership comparison was not, because that has to be written per handler by whoever wrote the handler.
That asymmetry is the root cause and worth naming explicitly. Authentication is often a platform feature; authorisation is often an application obligation. One is configured once and inherited everywhere. The other is re-implemented at every endpoint, by every author, forever, which is why it has holes and why the holes are where a newer or less trafficked route exists.
The reasoning to apply, stated as three questions against any endpoint that takes an object identifier: what object will this load if I change the value, whose session is making the request, and where in the code does anything compare those two facts. If the third answer is a shrug, that is the finding.
08 · Why obfuscation is not authorisation
The room's four forms are worth restating as a single idea, because it is the most durable thing here.
Encoding, hashing and randomising all operate on the identifier. Authorisation operates on the relationship between a principal and a resource. Changing the identifier's representation does nothing to the relationship, so it cannot substitute for enforcing it. A hashed identifier protects against nothing except a person reading numbers off a screen. An unpredictable identifier protects against nothing except enumeration, and only while it stays secret, which is a property of your deployment rather than of the object.
This is why the honest framing of a hardened-but-unfixed application is that discovery got harder, not that access got restricted. Practitioners should say that plainly, because the alternative is a security review in which the control is recorded as present when all that changed was the shape of the value.
09 · Defensive controls, logs and detections
| Weakness | Durable control | What to log and alert on |
|---|---|---|
| Object loaded from a caller-supplied reference (CWE-639) | Resolve the object from the principal and the request together, server-side: look the record up scoped to the authenticated account rather than loading it by identifier and then returning it. | Requests where the requested object's owner differs from the session principal, logged as a structured field rather than a message, and alerted when they succeed. |
| Protection attached to a route rather than an object (CWE-284) | Centralise the ownership check where the data access happens, so every route inherits it and a new endpoint cannot be added without it. | New endpoints returning records; coverage reports showing which handlers call the ownership check and which do not. |
| Identifier treated as a secret | Assume every identifier is known to the attacker. Do not rely on randomness, opacity or an unguessable format for an access decision. | Sequential or enumerable identifiers exposed in responses, and identifiers appearing in referrers, logs and shared links. |
| Authorisation only on the read path | Apply the same ownership check to writes, updates and deletes, and test them with the same method that found the read issue. | Write operations whose target object's owner differs from the session principal, and bulk operations that touch more than the requesting account. |
The first row is the one to action first, and its shape is worth internalising: the fix is not "check that the user may see this record" added at each handler. The fix is to make the lookup itself scoped, so that a record belonging to somebody else is not returned to the handler at all and cannot be forgotten about downstream.
10 · Misconceptions and troubleshooting
- "Hashed identifiers are secure because you cannot guess them." They are obscure, and obscurity only delays discovery. Once an identifier is seen, the object is reachable, and identifiers leak routinely through logs and shared links.
- "The page checks permissions, so we are covered." The page is one route. The same object is usually served by an API route as well, and the check lives in whichever handler its author remembered.
- "This is the same problem as authentication bypass." It is the next question along. Authentication asks who the caller is, and here that was answered correctly. Authorisation asks what they may reach, and that was answered by the caller.
- "Changing the identifier is exploitation." The finding is that the response contained another principal's data. The change is a test; the data returned is the evidence, and it should be captured as such.
- "A fix on one endpoint closes the issue." Test the other routes that return the same object, including writes, before calling it closed.
Troubleshooting comes down to which object was actually loaded. When a response looks correct but seems to belong to the wrong party, capture the request and the response together and confirm the identifier in the response matches the one you sent, because a mismatch there is itself an authorisation finding.
11 · Memory aids and practice checklist
Memory aid: a reference is a question, not an answer. Every identifier in a request is the client asking for an object. The application's job is to answer that question using the session, and an application that answers by loading whatever was named has delegated the decision to the requester.
- Identify every reference in a request, not just the obvious parameter.
- Record its form, and check whether the form changes anything about access.
- Test the same object through every route that serves it.
- Apply the same test to writes, updates and deletes.
- Ask what the server loaded, not what the interface showed you.
- State the exposure in the write-up: whose data, what fields, how many records at risk.
- Confirm the fix at the data-access layer, and re-test the other routes before closing.
12 · What the room teaches
The lesson, once: the caller may name an object, and may never grant themselves access to it. Every form of obfuscation in this room changes how easy the naming is, and none of them changes who is allowed to be named.
Read alongside the rest of this series it completes a set. The authentication bypass companion covers four ways into the same identity. The anonymous access companion covers a control that was never configured. This one covers an identity that is genuine and an object that was never assigned to it, which is the same failure as today's field note on where an agent's authority actually comes from, expressed in two forms of a request parameter.
Frequently asked questions
What is an insecure direct object reference?
It is an access-control failure where the application loads a record based on an identifier supplied by the caller, without verifying that the caller is entitled to that record. The session may be entirely legitimate. The failure is that the object was selected by the requester rather than resolved from the requester's own entitlements.
How is this different from authentication bypass?
Authentication establishes who the caller is. Authorisation establishes what that caller may reach. An IDOR leaves authentication intact and correct, and then permits the caller to choose the resource, so the wrong decision is made by the right process. That is why a fix aimed at authentication does nothing here, and why the two belong in separate findings.
Do unguessable identifiers fix IDOR?
No. Randomising or hashing an identifier raises the cost of discovering the value and leaves the authorisation decision exactly as it was. Once the value is known, through a log, a shared link, a response body or a support ticket, the object is reachable. The durable fix binds the object to the principal server-side so the value stops being an access decision.
How should an IDOR be reported?
State the object reached, whose data it was, the fields disclosed, and the number of records at risk if that can be established without exceeding scope. Capture the request and the response as the evidence. Describe it as an access-control finding on a specific endpoint, and list every other route that serves the same object, because that list is what tells the owner how much of the surface needs fixing.