Middleware and input
Handle uploads, sessions, and cookies
Treat uploaded bytes and session identifiers as untrusted inputs with controlled storage and lifetime.
An uploaded file and a session cookie are both input. The file is bytes somebody chose. The cookie is a string somebody can copy. Neither gets trust it has not earned. Let’s add both to the notes app with that in mind.
Uploads: limit, rename, store elsewhere
Multipart forms need a dedicated parser. I use multer:
npm install multer
Configure it with a size limit, a type filter, and a folder that is not served by express.static():
import multer from 'multer'
const upload = multer({
dest: path.join(import.meta.dirname, '../uploads'),
limits: { fileSize: 2 * 1024 * 1024 },
fileFilter(req, file, cb) {
cb(null, ['image/png', 'image/jpeg'].includes(file.mimetype))
},
})
pagesRouter.post('/notes/:id/attachment', upload.single('image'), (req, res) => {
if (!req.file) return res.status(400).send('A PNG or JPEG image is required')
attachments.save(req.params.id, req.file.filename, req.file.originalname)
res.redirect(303, `/notes/${req.params.id}`)
})
Three protections sit in that config. A file over 2 MB fails with a LIMIT_FILE_SIZE error. A file whose declared type is not an image is dropped, so req.file is missing. And multer names the stored file with a random string, so ../../etc/passwd.png from the client never becomes a path on your disk. The original name goes in the database as a label, nothing more.
file.mimetype comes from the client, so a renamed executable can still claim to be a PNG. When it matters, read the first bytes of the saved file and check for the real PNG or JPEG signature before you serve it back.
The uploads/ folder is outside public/. To show an attachment, write a route that looks up the record, checks the owner, and calls res.sendFile(). Never let the browser address uploaded files directly.
Sessions: opaque id, real store
Install express-session and configure it from the config object we built earlier:
import session from 'express-session'
app.use(session({
name: 'notes.sid',
secret: config.sessionSecret,
resave: false,
saveUninitialized: false,
store: sessionStore,
cookie: {
httpOnly: true,
secure: config.production,
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000,
},
}))
The browser gets a cookie holding only a signed session id. The user id lives on the server in req.session.userId. Tampering with the cookie breaks the signature, and the request arrives with no session.
saveUninitialized: false means visitors who never log in get no cookie at all. httpOnly keeps scripts away from it, secure sends it only over HTTPS, and sameSite: 'lax' blocks it on cross-site POST requests, which shuts down the classic CSRF form attack.
Login sets the id, logout destroys the whole session:
req.session.userId = user.id
req.session.destroy(() => res.redirect(303, '/login'))
The memory store is a trap
Leave store out and Express uses an in-memory store. Start with NODE_ENV=production and you see:
Warning: connect.session() MemoryStore is not designed for a production environment
Every restart logs everyone out, and two instances never share a login. Use a store backed by what you already run, like connect-redis or connect-pg-simple.
Try this: log in, restart the server, reload. With the memory store you’re logged out. With a real store you’re not. Then upload a .txt renamed to .png and confirm req.file still arrives, which is why the byte check exists.
Lesson completed