Skip to content

How to think about security

A feature can work perfectly and still be unsafe. Imagine a tiny order route:

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

The route reads an id, finds an order and sends it back. If you test it with your own order, it looks fine. Now change the id in the URL. If the server never checks who owns that order, one signed-in customer can read another customer's receipt.

That is the security mindset: don't only ask whether the code works. Ask what someone could make it do.

JunoHow to think about security Working code answers one question: does this feature do the thing we wanted?

Security asks a second question: what happens when someone uses it in a way we didn't intend?

JunoHow to think about security The bug in the order route isn't a JavaScript bug. The handler runs, the query returns, and the response is valid JSON.

The review move is to name the decision out loud: this code checks whether an order exists, but not whether this user may read it. Once you can name the missing decision, you can write the missing test.

JunoHow to think about security Most application security bugs are ordinary product paths with a hostile caller in the room. The product path still works, which is why the happy-path test smiles at you while the incident report sharpens its teeth.

Read the feature as claims under stress: the URL claims an id, the session claims a user, the body claims intent. The server has to decide which claims become facts.

Notice the boundary

A trust boundary is the line between code you control and something that can send you a value you didn't choose.

The browser sits on the other side of that line. A user can change the URL, edit a hidden form field, repeat a query parameter, remove a cookie, or skip your front end and send a request by hand.

That makes the browser the wrong place to make decisions you care about.

Browser checks are useful, but they are not the control

Client-side validation helps people fill out forms. It catches typos early and gives a nicer experience.

The server still has to check the value again, because the server is the part that protects the data.

Here is the same order route with the missing decision made explicit:

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 important line is the comparison between order.userId and req.user.id. The server is saying: this order may exist, but it also has to belong to the person asking for it.

A request is allowed when the server can prove the decision for itself.

JunoNotice the boundary Treat the browser like a visitor at a reception desk. It can ask for things, but it doesn't get to decide what it is allowed to see.

Put the important checks on the server. That is where your app can compare the request with the data it already trusts.

JunoNotice the boundary A trust boundary is where a value crosses from a place you don't control into code that will make a decision.

Useful review shortcut: list every value the handler reads, then mark which ones came from the caller. Anything caller-chosen needs a server-side rule before it affects data, identity, permissions or workload.

JunoNotice the boundary Boundaries reach past network edges. Background jobs, admin tools, webhooks, environment config and internal services can all carry values from somewhere your current code did not choose.

The line belongs wherever your willingness to absorb another system's failure changes. Once you draw it, you owe the crossing a check, a test, an error path and some poor future engineer a clue about why it exists.

Name what could break

Once you spot a risky decision, give the possible failure a name:

QuestionSecurity wordExample
Who can read this?ConfidentialityA customer reads another customer's order.
Who can change this?IntegrityA customer changes the price before checkout.
Can people still use this?AvailabilityOne request makes the app slow for everyone.

Together these are called the CIA triad: confidentiality, integrity and availability.

A leaked receipt is a confidentiality problem. A hidden price field is an integrity problem. A search route that lets one request ask for a million records is an availability problem.

Some bugs touch more than one. If one customer can edit another customer's delivery address, they can both see it and change it.

JunoName what could break Use the three questions before the three names. Who can read it? Who can change it? Can people still use it?

Those questions turn a vague worry into something you can explain. A bug report gets clearer when you can name the harm.

JunoName what could break Confidentiality, integrity and availability help you choose the right fix.

A read leak needs an access check. A wrong-user change needs server-side authority. A costly route needs limits on size, rate or time. Same route, different property, different repair.

JunoName what could break The three properties expose the tradeoffs people prefer not to write down. Account lockout protects confidentiality against guessing, but gives attackers a way to block real users.

Availability is the property teams trade away on purpose most often. Name the trade in the design, or someone will rediscover it during an outage with colder coffee and fewer options.

Think from the other side

The phrase "think like an attacker" can sound theatrical. In day-to-day development, it is quieter. Pick one input and ask:

  • What if this value is missing?
  • What if it is much longer than expected?
  • What if it is the wrong type?
  • What if it belongs to another user?
  • What if the same request is sent thousands of times?

Take a profile form. The page might show a text input for displayName, a hidden userId, and a submit button. The intended path is small: the signed-in person updates their own name.

The security path asks different questions. Why is userId in the form at all? What happens if it points at someone else? Does the server use the signed-in session, or does it trust the submitted field?

Hidden does not mean trusted

A hidden form field is hidden from the page, not from the person sending the request.

If the value matters, derive it on the server from the session or database. Don't ask the browser to send back a decision the server already knows.

The same habit works outside forms. A file upload claims a filename and a content type. A token claims an identity. A webhook claims an event happened. Check the claim at the point where your code is about to depend on it.

JunoThink from the other side Start with one field. Ask what happens if it is missing, too long, the wrong shape, owned by someone else, or sent over and over.

That is security thinking in a small, usable form.

JunoThink from the other side The useful version of attacker thinking is input thinking. Read the handler and mark every claim the caller makes: identity, id, role, price, filename, return URL, content type.

Then ask where each claim is checked. If a claim affects a decision before that check happens, you have a bug shape worth testing.

JunoThink from the other side Rank the questions by reachability. A bug a normal signed-in user can trigger with one request deserves attention before one that needs timing, insider access or an old deployment flag from 2019.

That doesn't make edge cases harmless. It keeps the review focused on the paths real attackers, bored users and automated scanners will try before lunch.

Where this goes next

This mindset is the setup for the next part of the course.

Threat modelling asks what could go wrong before a feature ships. OWASP gives you shared names for bugs in live apps. Bug triage helps you decide what to fix first.

Keep the loop small:

  1. Find the boundary.
  2. Name what could break.
  3. Check the claim on the server.