Skip to content

Bug triage

A message lands in the team chat:

I think customers can see each other's orders.

That might be urgent. It might be a misunderstanding. Nobody knows yet.

Bug triage is the work between "someone found something" and "the team knows what to do next."

For security bugs, triage answers four questions:

  1. Can we reproduce it?
  2. What harm does it cause?
  3. How hard is it to use?
  4. What happens next?

Reproduce the bug

A security finding is a claim until someone can make it happen on purpose.

Start with the vulnerable route:

Vulnerable
js
app.get('/orders/:id', async (req, res) => {
  const order = await db.orders.findById(req.params.id)
  res.json(order)
})

Now write the reproduction:

bash
# Signed in as customer 4102
curl -i https://app.example.com/orders/88213 \
  -H "Cookie: sid=<session for customer 4102>"

# Expected: 403 or 404
# Actual: 200, with another customer's order

A good reproduction includes the account, the request, the expected result and the actual result.

"Change the id and it breaks" is a note. The block above is something another developer can run.

Only test where you have permission

Run reproductions against systems you own, maintain, or have written permission to test.

The same request against someone else's app isn't triage.

JunoReproduce the bug A reproduction is the recipe for making the bug happen again.

Write the account, the request, what should have happened and what actually happened. If another developer can follow it without asking questions, you have a useful report.

JunoReproduce the bug Reduce the bug to the smallest request that still proves it. Remove extra headers, steps and data until taking away anything else makes the behaviour disappear.

That smaller request is easier to test, easier to fix and harder to dismiss. It also tells you which part of the system is actually making the unsafe decision.

JunoReproduce the bug Reproduce first for a suspected bug. Contain first for a bug already being used.

If logs show unfamiliar accounts walking order ids, or customers report seeing data that was never theirs, preserve evidence and scope access first.

Don't spend the best hour of the incident proving the bug to yourself. The reproduction can wait long enough for the logs not to evaporate, and logs love evaporating. It's one of their least charming hobbies.

Explain the impact

Impact means what the person using the bug ends up holding or doing.

For the order bug, the impact might be:

Any signed-in customer can read another customer's delivery address and order history.

That sentence is better than:

IDOR on /orders/:id.

IDOR is the security name: insecure direct object reference. It means a request includes an id for an object, and the server doesn't check whether the caller is allowed to use that object.

The name helps engineers classify the bug. The impact helps everyone understand why it matters.

Ask:

  • What data or action is exposed?
  • Whose data or action is it?
  • How many people could be affected?
  • What could this enable next?

Severity starts with the harm, not the cleverness of the bug.

JunoExplain the impact Impact is the harm in plain language. What does the person get to read, change or do?

Security names are useful, and they aren't enough on their own. "Any customer can read another customer's address" tells more people what matters than "IDOR".

JunoExplain the impact When you don't know the exact count, give a bound. "All orders since March, roughly 40,000" is better than leaving the field empty.

Then follow the harm one step. An exposed email address can make phishing easier. An exposed reset token can become account takeover. One step keeps the report grounded; five steps turns it into fan fiction with a severity label.

JunoExplain the impact Some findings have no reachable impact today, and forcing one into the report makes the whole queue worse. A missing cookie flag on a route that accepts no cross-site write is a real gap, but not a live account-takeover story.

File it as "no reachable impact today", then write what would make it matter. That future condition is the useful part. It is the breadcrumb someone needs when the route changes six months later and the old low-risk finding becomes suddenly less decorative.

Judge exploitability

Impact tells you what the bug can do. Exploitability tells you what it takes to do it.

For the order bug, the attacker needs:

  • a normal customer account
  • one valid or guessable order id
  • one request

That is highly reachable. A bug that needs an admin account, physical access, perfect timing and a victim click is different.

Write the preconditions down. They keep the severity conversation concrete.

PreconditionsWhat it means
AnonymousAnyone on the internet can try it.
Signed inA normal account is needed.
Privileged roleA staff or admin role is needed.
Victim actionSomeone else must click or submit something.
Guessable idThe target can be found by trying values.
Timing windowThe bug works only during a narrow moment.
JunoJudge exploitability Exploitability is the list of things that must be true before the bug works.

Anonymous is easier to reach than signed in. Signed in is easier to reach than admin. One request is easier to reach than a chain that needs timing and another person clicking something.

JunoJudge exploitability Treat preconditions as claims to verify. If the report says "admin required", check how many admins exist and how that role is granted.

If it says ids are unguessable, look at real ids. A timestamp with a counter on the end isn't unguessable because the variable was named nicely. Naming has betrayed better people than us.

JunoJudge exploitability Exploitability changes when the surrounding system changes. A finding rated low because it needs a staff account becomes a different finding when support tooling opens to partners.

Put the date and reason beside the rating. Future you, or the person inheriting your service after three reorganisations, needs to know which assumption held the rating up. Assumptions age like milk in a server room.

Decide what happens next

Now combine the pieces:

FieldExample
ReproductionCustomer 4102 requests order 88213 and receives another customer's order.
ImpactAny signed-in customer can read another customer's delivery address and order history.
ExploitabilityNormal account, guessable id, one request.
OWASP categoryA01 Broken Access Control.
Next actionFix before release, add ownership test, check similar routes.

The OWASP category helps classify the bug. The next action turns the report into work.

For this bug, the fix is to check ownership before returning the order:

Fixed
js
app.get('/orders/:id', async (req, res) => {
  const order = await db.orders.findById(req.params.id)

  if (!order || order.userId !== req.user.id) {
    return res.status(404).json({ error: 'Order not found' })
  }

  res.json(order)
})

The matching test should prove the bad path, not only the happy path.

A good triage note creates follow-up work

Fix this endpoint.

Add a regression test.

Search for the same pattern in nearby routes.

JunoDecide what happens next Triage ends with a decision. Fix it now, fix it later with a reason, or accept the risk on purpose.

For this order bug, the decision should be direct: fix before release, add a test, and check nearby routes for the same missing ownership check.

JunoDecide what happens next The follow-up matters as much as the fix. A broken access control bug on one route is a hint to search the rest of the resource.

Look for handlers that read req.params.id and return or change data without comparing ownership, role or tenant. That search turns one report into a class fix.

JunoDecide what happens next The fix has three layers: repair the route, lock the bug with a regression test, and look for the pattern elsewhere. Skip the third and you get to rediscover the same bug with a different URL and a fresh sense of disappointment.

Be careful with response choices too. Returning 404 for missing and unauthorized orders hides whether the id exists. Returning 403 can be clearer for clients but may leak that the record is real. Pick the behaviour deliberately and keep it consistent per resource.

Try it

A bug report lands with one line in it: "The password reset link still works after you've used it."

Triage it. Fill in the five fields: reproduction, impact, exploitability, OWASP category, next action.

Compare your answers
FieldA reasonable answer
ReproductionRequest a reset, follow the link, set a password, then open the same link again. It's still accepted and lets you set another password.
ImpactAnyone who sees that link, once, can take the account over later. Reset links sit in inboxes, browser history, and forwarded emails for a long time.
ExploitabilityNo account needed and no guessing. It requires access to the link, so the difficulty is entirely about where that link has travelled.
OWASP categoryA07 Authentication Failures. The app can't reliably establish who someone is when a spent credential still works.
Next actionInvalidate the token on first use, and add an expiry if there isn't one. Then check whether email verification and invite links have the same flaw.

Two things worth pulling out.

The impact isn't "a token isn't invalidated", it's "someone can take the account over". A field that restates the bug adds nothing; the field is there to say who gets hurt.

And the next action ends by looking sideways. Single-use tokens are usually generated by shared code, so a bug in one flow is very often present in the others. Triage that fixes exactly one route and stops has done half the job.

Where this goes next

Bug triage closes the first section. You can now ask what could go wrong, name common live bugs, and turn a finding into work.

Next, the handbook moves into input and data safety, starting with never trust user input.