PLAINFINCH learn by building

Protecting private pages

You will be able to guard private pages and APIs so only logged-in visitors with the right role can reach them.

Why does every private route need its own check?

Send GET /api/orders from the starting code. The bakery's orders come back to anyone, with 200. The login form works and sessions are random, but nothing connects the two: a route only knows who is asking if it looks. Every request is a stranger until the route checks the session.

The same is true of links and buttons. Hiding a link to the orders page from people who aren't staff changes what the page shows, and nothing about what the server answers. Anyone can type the address, send it from a script or from a tool like the request bar here. The rule is short: every private route checks, on the server, on every request.

How does a login middleware work?

Middleware is a function Express runs before your route handler. It gets req, res and a third argument, next. It can do one of two things: answer the request itself, or call next() to pass it along to whatever comes after. A login check fits that shape exactly. Find the session; if there isn't one, answer with an error; if there is, attach the user to the request and call next().

You add it to a route by listing it before the handler: app.get('/profile', requireLogin, handler). Express runs them left to right. The handler can then trust req.user, because it never runs without one. Attaching the user to req also saves each handler from looking the session up again, and gives later middleware, such as a role check, something to read.

function requireLogin(req, res, next) {
  const user = sessions.get(req.cookies.sid);
  if (!user) return res.status(401).json({ error: 'Log in first' });
  req.user = user;
  next();
}

app.get('/api/orders', requireLogin, (req, res) => {
  res.json(orders);
});

Why does return matter in middleware?

Answering a request doesn't stop the function. If the middleware calls res.status(401).json(...) and then reaches next(), the route runs as well, for a visitor who isn't logged in. The route may leak data into the Log, crash on req.user.name, or try to answer a second time and produce ERR_HTTP_HEADERS_SENT.

Write return res.status(401)... every time you answer early. The opposite mistake is quieter. A middleware that neither answers nor calls next() leaves the request hanging, and the Requests view shows **No reply**.

When should you send 401, and when should you redirect?

It depends on who is asking. A program calling /api/orders, such as the page's own fetch() code or an app, needs a status code it can act on. 401 Unauthorized means “I don't know who you are, log in and try again”. Send it with a short JSON error. A redirect would hand the program an HTML login form it can't use.

A person opening /profile in a browser needs the login page itself, so send a redirect to /login. Many sites add the address they were trying to reach, such as /login?next=/profile, and send them back after login. If you do, only accept paths that start with / and not with // or /\, which browsers treat as another site, or an attacker can craft a link that bounces people to their site after a real login.

One middleware can serve both by looking at the address. In the solution, req.originalUrl.startsWith('/api/') picks the API answer. req.originalUrl is safer than req.path here, because req.path changes when routes live in a router mounted at /api.

FigurerequireLogin looks up req.cookies.sid in sessions. With no session, /api/orders gets 401 Log in first and /profile is redirected to /login. With a session it sets req.user and calls next, so the route runs.requireLogin looks up req.cookies.sid in sessions. With no session, /api/orders gets 401 Log in first and /profile is redirected to /login. With a session it sets req.user and calls next, so the route runs.
One middleware guards both routes. A missing session ends the request right there, with 401 for the API and a redirect to /login for the page; only a found session reaches next().

What is the difference between 401 and 403?

401 is about identity: the server doesn't know who is asking, or the session has expired. Logging in can fix it. 403 Forbidden is about permission: the server knows exactly who is asking, and the answer is still no. Theo is a baker. If he asks for the shop's takings, the right answer is 403, because logging in again won't change anything.

Getting this right helps the browser code too. A page can react to 401 by showing the login form, and to 403 by explaining that this needs the owner. Some sites answer 404 instead of 403 for things a user shouldn't even know exist, such as another member's private page. That is a reasonable choice when the existence of the thing is itself private.

function requireOwner(req, res, next) {
  if (req.user.role !== 'owner') {
    return res.status(403).json({ error: 'Only the owner can do that' });
  }
  next();
}

app.get('/api/takings', requireLogin, requireOwner, (req, res) => {
  res.json({ today: 386 });
});

How do you protect many routes at once?

Listing requireLogin on every route works and is easy to read. When a whole area is private, mount the middleware once instead. app.use('/api', requireLogin) runs it for every request under /api, and a router lets you do the same for a group of pages. Order matters: anything registered before the app.use line isn't protected by it, so put the public routes, such as /login, first or outside the protected path.

Check objects as well as routes. A logged-in member asking for /api/sightings/7 should only get sighting 7 if it is theirs or public. Compare the record's owner with req.user before answering. Forgetting this check is one of the most common security bugs on real sites, and a login check alone doesn't catch it.

Keep the checks in one place. A requireLogin and a requireRole('owner') written once and reused are easy to review, while the same if copied into twenty handlers drifts apart over time, and one copy eventually gets it wrong. When a new private route appears, the question to ask is short: which middleware runs before it?

How can you test protected routes here?

Test from the outside, like an attacker would. Send each private route with no cookie, then with a made-up sid using the request bar's headers, then logged in as each role. Read the status in the Requests view each time: the line that names the matched route also shows whether the handler ran.

Try the edges as well: a session that was logged out, a request with the cookie but a different method, such as DELETE, and an API path with a trailing slash. Read the Log after each attempt. A clean 401 adds nothing alarming. ERR_HTTP_HEADERS_SENT or a TypeError about user means the middleware let the request through after answering.

What do AI tools get wrong about protecting routes?

Generated front-end code often hides admin buttons and stops there. Generated server code often checks only that a cookie exists, without looking it up, or trusts a role field sent in the request body. Check that each private route has a server-side check, that it reads the session from the store, and that the role comes from the session.

Look at the status codes too. AI code tends to use 401 for every refusal, or 200 with { success: false }, which caches badly and confuses the page's error handling. Look for the missing return before an early answer, and for new routes added later without the middleware. Mounting app.use('/api', requireLogin) once makes that last mistake harder.

Field notes

  • Every private route needs a check on the server, since hiding a button protects nothing.
  • A login middleware either answers the request itself or calls next() to let it through.
  • Put return before a response in middleware so the route doesn't run as well.
  • Send 401 when the server doesn't know who you are, and 403 when you lack permission.

Easy to get wrong

The wrong version checks only that some cookie exists, which anyone can fake, and it carries on to next() after answering. Look the id up, and return the early answer.

The user is logged in, so 401 is the wrong signal. 403 tells the page that logging in again won't help.

Anything in the request body, query or headers comes from the visitor. Roles must come from the session the server stored.

Questions people ask

Where should the login page itself live?

Outside the protection. If /login needs a login, nobody can reach it. Register public routes before app.use('/admin', requireLogin), or keep them on paths the middleware doesn't cover.

Should a 401 response include a WWW-Authenticate header?

The HTTP standard expects one with 401, naming how to log in. Sites using a login form and cookies often leave it out, and browsers cope. If you build an API that uses tokens, send WWW-Authenticate: Bearer.

Is it enough to protect the API if the page is public?

The data lives in the API, so that is the part that must be protected. A public page that shows nothing until its fetch() succeeds is fine. Protect the page too when its HTML itself contains private information.

How do I log someone out of every device?

Store the member's name or id with each session, and on “log out everywhere” delete every session that belongs to them. That is easy with a database table of sessions and one reason live sites use one.

Keep going