You logged in. Are you allowed? · slopware 101

slopware 101 · step 18 of 35

You logged in. Are you allowed?

by · 4 min read

A calendar draft can be accurate and still belong to the wrong person. Correct contents do not settle who may read or change them. Every request carries two separate questions: who is making it, and what may this particular person do?

A login helps establish an identity. The permissions question returns whenever a request names a note, draft or other record. Knowing the person's name is not enough to answer it.

From password to session

Start with the first question, because the second is built on top of it. When someone types a password, the server compares it with a stored hash. A successful check authenticates the account; it does not establish who physically holds the keyboard. But HTTP has no memory. The next request, a second later, arrives as a stranger again. So at login the server hands back something the browser will carry on every following request. That something is a session.

There are two common shapes. In the first, the server creates a random session id, stores it next to the user id in a table or a cache, and sends the id back in a cookie. In the second, the server signs a token, often a JWT, that contains the user id itself, and the browser sends that token back. Either way, what travels is proof that a login happened, not the password.

Where that proof sits matters. JavaScript on the page cannot read a cookie marked HttpOnly, so a script injected into your page cannot copy it. A script executing with the page's origin can read a token kept in localStorage. HttpOnly does not stop injected JavaScript from making authenticated requests through the browser, so the cookie flag does not solve cross-site scripting by itself. Secure keeps the cookie off unencrypted connections. SameSite limits when the browser attaches the cookie to requests started by other sites. None of these flags decide what the user may do. They only protect the proof of who the user is.

After login, then, each request answers the first question on its own. The server validates the session and gets a user id from it. For a JWT, decoding its fields is not verification: check the signature with the trusted key and allowed algorithm, expiry, and the expected issuer and audience before accepting the identity. That id is identity. Nothing more has been decided yet.

Ownership is a separate check

Picture a small invoices app. A request arrives:

GET /api/invoices/8812

The session says user 17. The URL says invoice 8812. Identity is settled, but the question that matters has not been asked: does invoice 8812 belong to user 17?

The version that gets written first looks like this:

app.get('/api/invoices/:id', requireLogin, async (req, res) => {
  const invoice = await db.invoices.findById(req.params.id);
  res.json(invoice);
});

requireLogin answers who. Nothing answers whose. Any logged-in account can walk the ids one by one. The fix puts ownership into the query itself, so the database only returns rows this session is entitled to:

const { rows } = await pool.query(
  'select * from invoices where id = $1 and owner_id = $2',
  [req.params.id, req.session.userId]
);
const invoice = rows[0];
if (!invoice) return res.status(404).end();
return res.json(invoice);

Notice the default. If the server cannot match the id with this person, the answer is no. Answering 404 instead of 403 also avoids confirming that invoice 8812 exists. Random ids such as UUIDs make guessing harder, but they are not a check. An id that leaks through a shared link or a log is still an id.

Testing with one account can hide the missing check: every record created during development belongs to that account. A rule saying this row is not yours has nothing to reject while you are the only person in the database. The app looks correct because you are testing the one case where the missing rule cannot show itself.

The browser is not a place to decide

When the check does get written, it tends to appear where you can watch it work. The delete button renders only for admins, and the panel hides behind a flag. Yet the browser is a machine the visitor owns and edits at will. Hiding the button hides the button, and anyone can still send the request underneath by hand. Checks in the browser are good manners for the interface. The decision itself has to live on the server that owns the rows.

Try it with two accounts

Do this only on an app you own, ideally a local or staging copy. Create two test accounts, A and B. As A, create a record and note its id from the URL or the browser's network tab. Log out, log in as B, and request that id directly, either by editing the URL or by replaying the request with B's session. Then try the write operations too: update and delete.

For records intended to be private to A, B must not receive or change their contents. Some APIs express a denied read as an empty result rather than an HTTP error. Inspect the data and the resulting state, not only the status code.