Skip to content

Static Files

Static file serving sounds simple. Serve files from a directory. But misconfigure it and you can serve your entire source code including .env files , node_modules , and database credentials
Express's express.static() is a built-in middleware that serves files from a directory. Simple on the surface. Dangerous underneath

express.static() basics

const express = require('express')
const app = express()

// serve everything in 'public' directory
app.use(express.static('public'))

Now any file in public/ is accessible via URL:

flowchart LR
    root["public/"] --> css["css/"]
    css --> sc["style.css"]
    sc --> rcss["/css/style.css"]
    root --> js["js/"]
    js --> aj["app.js"]
    aj --> rjs["/js/app.js"]
    root --> img["images/"]
    img --> lp["logo.png"]
    lp --> ri["/images/logo.png"]
    root --> ih["index.html"]
    ih --> rih["/index.html"]

multiple static directories

app.use(express.static('public'))
app.use(express.static('uploads'))
app.use(express.static('dist'))

Express checks each directory in order and serves the first match. Same filename in multiple directories? First directory wins

virtual path prefix

Serve files under a URL prefix that doesn't match the filesystem path

// files in 'public' appear under '/static'
app.use('/static' , express.static('public'))

// public/css/style.css  ->  /static/css/style.css
// public/js/app.js      ->  /static/js/app.js

This is a security move too - attackers don't learn your filesystem structure

caching headers

Express sets Cache-Control and ETag headers by default

// override default caching
app.use('/static' , express.static('public' , {
  maxAge: '1d',         // cache for 1 day
  etag: true,           // enable ETag (default)
  lastModified: true,   // enable Last-Modified (default)
  setHeaders: (res , path) => {
    if (path.endsWith('.html')) {
      // don't cache HTML files
      res.setHeader('Cache-Control' , 'no-cache')
    }
  }
}))

Production tip: Set far-future Cache-Control for hashed filenames (style.a1b2c3.css) and short/no cache for non-hashed files

security - hide directory listing

By default Express does NOT serve directory listings. Good. Keep it that way

// This is SAFE - Express doesn't list directories
app.use(express.static('public'))

If you're using a different static file tool (like serve-index), never enable it in production Directory listing gives attackers a map of your application assets , versioned filenames , and potential backup files

what NOT to serve

Never ever serve these directories:

// DANGEROUS - serves everything including source
app.use(express.static('.'))       // serves entire project
app.use(express.static('../'))     // serves parent directory

// SLIGHTLY LESS DANGEROUS but still bad
app.use(express.static('src'))     // serves source code
app.use(express.static('node_modules')) // serves all dependencies

What happens if you serve the project root:

GET /package.json          -> your dependencies , scripts , project name
GET /.env                  -> database credentials , API keys
GET /server.js             -> your application source code
GET /node_modules/         -> all 50000 dependencies accessible

dotfiles option

Control how files starting with . are handled:

// default: 'ignore' - dotfiles are not served
app.use(express.static('public'))

// explicit options:
app.use(express.static('public' , {
  dotfiles: 'ignore'   // don't serve .env , .gitignore (DEFAULT)
  // dotfiles: 'allow'   // serve dotfiles (DANGEROUS)
  // dotfiles: 'deny'    // return 403 for dotfiles
}))

Keep the default. There is never a reason to serve dotfiles

path traversal prevention

Express.static prevents path traversal attacks by default - GET /../../../etc/passwd won't work

// This is SAFE - Express normalizes the path
// GET /../../../etc/passwd   -> 404
// GET /..%2f..%2f..%2fetc/passwd  -> 404 (if properly configured)

But don't rely on this alone. Add your own checks:

app.use('/static' , (req , res , next) => {
  // block path traversal attempts explicitly
  if (req.path.includes('..') || req.path.includes('%2e')) {
    return res.status(403).send('Forbidden')
  }
  next()
}, express.static('public'))

complete production setup

const express = require('express')
const path = require('path')
const app = express()

// static files under /static prefix
app.use('/static' , express.static(path.join(__dirname , 'public') , {
  maxAge: process.env.NODE_ENV === 'production' ? '7d' : 0,
  etag: true,
  lastModified: true,
  dotfiles: 'deny'
}))

// directly serve index.html at root
app.get('/' , (req , res) => {
  res.sendFile(path.join(__dirname , 'public' , 'index.html'))
})

prerequisites

express_04_middleware.md - middleware patterns , app.use() , execution order


next → express_06_error_handling.md