Errors, security, and observation
Configure proxies, CORS, and headers
Set trust proxy and cross-origin policy from the real deployment path instead of copying permissive snippets.
In production the notes app sits behind a reverse proxy. Caddy or nginx terminates TLS and forwards plain HTTP to Node. That changes what Express can see, and two settings depend on it: which proxy to trust, and which browser origins may talk to the API.
Both have a permissive one-liner that makes the demo work and is wrong in production.
Trust exactly the hops you have
Behind a proxy, req.ip is the proxy’s address and req.protocol is http, because that’s what Node received. The proxy puts the real values in X-Forwarded-For and X-Forwarded-Proto, but Express ignores those headers until you say who may set them:
app.set('trust proxy', config.trustProxy)
trustProxy comes from the config lesson: 1 in production, meaning one hop, and false locally. With 1, Express takes the client address from the last entry of X-Forwarded-For, the one your proxy added, and req.protocol becomes https.
That last part is why secure cookies work at all. express-session refuses to set a secure cookie on a request it thinks is plain HTTP. Without trust proxy, nobody can log in and nothing in the logs looks wrong.
The wrong version is app.set('trust proxy', true). It trusts every hop, so any client can send X-Forwarded-For: 10.0.0.1 and become whoever they like in your rate limiter. Count your proxies and trust that number.
Check it with a direct request and a forged one:
curl -s http://localhost:3000/api/whoami
curl -s -H 'X-Forwarded-For: 1.2.3.4' http://localhost:3000/api/whoami
Locally, with trustProxy: false, both return your loopback address. The forged header is ignored.
Allow one origin, not all of them
CORS is about browsers. It controls which web pages may read responses from your API. It does not stop curl or a server from calling you. So the question is: which pages are mine?
For the notes app it’s one frontend. Install cors and say so:
import cors from 'cors'
apiRouter.use(cors({
origin: 'https://notes.flaviocopes.com',
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type'],
credentials: true,
}))
credentials: true lets the browser send the session cookie along, and it’s the reason the origin must be a real name. Browsers refuse Access-Control-Allow-Origin: * on a request with credentials, so cors() with defaults plus cookies fails in the console. And origin: true, which reflects whatever origin asked, turns every website into your frontend.
Test both sides:
curl -s -I -H 'Origin: https://notes.flaviocopes.com' http://localhost:3000/api/notes | grep -i access-control
curl -s -I -H 'Origin: https://evil.example' http://localhost:3000/api/notes | grep -i access-control
The first prints Access-Control-Allow-Origin: https://notes.flaviocopes.com and Access-Control-Allow-Credentials: true. The second prints nothing, so a browser on that page cannot read the body.
Add the defensive headers
A few response headers cost nothing and close common holes. helmet sets them in one call:
import helmet from 'helmet'
app.use(helmet())
You get X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN, Strict-Transport-Security, a strict default Content-Security-Policy, and no more X-Powered-By: Express. If your proxy already sets these, set them in one place only.
Try this: set origin: true, rerun the second curl, and watch your API say yes to evil.example. Then put the real origin back.
Lesson completed