GUIDE · OWASP Top 10:2025
A02:2025 Security Misconfiguration
The code is fine; the settings around it are not — insecure defaults, debug features, missing hardening, exposed internals.
At a glance
- WHAT
- The code is fine; the settings around it are not — insecure defaults, debug features, missing hardening, exposed internals.
- HAPPENS
- verbose stack traces in prod; default credentials (admin/admin); directory listing; DEBUG=true / NODE_ENV unset; CORS '*' with credentials; missing security headers (CSP, HSTS, X-Content-Type- Options, X-Frame-Options); unnecessary HTTP methods (TRACE, PUT); public cloud bucket / open S3 ACL; unpatched platform; sample apps.
- SPOT
- .env committed;
app.use(express.static(root))on repo root; error handler sending err.stack;cors()with no options;X-Powered-By: Express; Swagger/GraphQL playground reachable in prod; recon: /.git/, /.env, /server-status, /actuator, /debug. - FIX
- config loaded from env and VALIDATED at startup (fail fast); fail-closed defaults; helmet-style headers; generic error pages; least-privilege IAM; infrastructure-as-code reviewed; hardened golden images; automated config scanning (CIS benchmarks).
- SAY
- CWE-16 Configuration, CWE-209 Information Exposure Through Error Message, CWE-1104 Unmaintained Components, CWE-942 Permissive CORS. Pitch: "Secure by default, verified at boot, same config path for dev/stage/prod with only the values differing."
The idea#
Picture a house with a solid door and a good lock. The spare key is under the mat. The alarm code is on a sticky note in the window. A little sign names the brand of lock. The walls were built correctly. The way you left the house is the problem.
That is Security Misconfiguration. The code can be careful while the settings around it are loose. The real control is configuration you load once at startup, check before the program accepts traffic, and freeze. Debug mode stays off unless someone opts in with an explicit true. A session secret has to be long, and a famous default is rejected. A wildcard cross-origin setting is rejected in production. Responses carry a content security policy, strict transport security, a nosniff header, and a frame denial. Headers that advertise your framework come off. The browser hears a generic error. The full detail stays in the log with a request id. Methods you did not list are refused. Shipped-in pairs such as admin and admin are refused.
Failure looks like the key under the mat. The process starts with debug unset and treats that silence as on. The session secret falls back to a word everyone has already tried. A production error page prints a stack, a file path, and a database address. Directory listing is on. A cloud bucket is world-readable. A sample admin screen is still mounted. Your laptop and production disagree because nobody checked the values at boot.
Why it matters#
This category ranks high on the 2025 OWASP list because defaults are enough. A stranger does not need a clever bug in your logic when the sample password still works, the error page draws a map of the server, or the browser is allowed to frame your site. Misconfiguration lives in environment variables, framework defaults, headers you forgot, and cloud consoles left on the vendor's easy option.
A stack trace teaches your layout. A missing nosniff header lets a browser treat a file as something more powerful than you intended. A missing frame policy lets another site wrap yours. A default credential is a key under the mat for every copy of the product. An open bucket is a folder on the sidewalk.
Load config in one function. Require NODE_ENV to be a known name. Require the secret. Treat debug as on only when the value is the string true. Use one code path for development, test, and production, and let only the values change. Fail the boot when production asks for a wildcard origin or a debug flag. Put the header set in one frozen object and apply it in one place. In production the client hears "Internal Server Error" plus a request id. You keep the rest for the log.
A small example#
function loadConfig(env) { const errors = []; if (env.NODE_ENV !== 'production' && env.NODE_ENV !== 'development') { errors.push('NODE_ENV invalid'); } if (!env.SESSION_SECRET || env.SESSION_SECRET.length < 32) { errors.push('SESSION_SECRET too short'); } if (env.NODE_ENV === 'production' && env.DEBUG === 'true') { errors.push('DEBUG on in production'); } if (errors.length) throw new Error(errors.join('; ')); return Object.freeze({ isProd: env.NODE_ENV === 'production', debug: env.DEBUG === 'true', });} const config = loadConfig({ NODE_ENV: 'production', SESSION_SECRET: 'x'.repeat(32),});config.debug is false. Production has to opt in. A short secret, or a NODE_ENV you do not recognize, throws before the process listens.
What the code below is doing#
The page shows five pieces of hardening.
Startup validation is loadConfig. It accepts production, development, or test. It rejects a missing or short session secret and the literal default changeme. In production it rejects DEBUG=true and a CORS origin of *. Debug is true only when the variable is the string true. The returned object is frozen. exposeErrors is on only outside production.
Security headers are a frozen map applied by applyHeaders. You get a content security policy, strict transport security, nosniff, frame denial, a referrer policy, a permissions policy, and cross-origin policies. Authenticated responses are marked no-store. X-Powered-By and Server are removed.
The error handler is errorResponse. Production returns a generic message and the request id. Development may add err.message. The stack stays in the log. The asserts show a database address present on the error object and absent from the production JSON.
Method and credential checks are small predicates. methodAllowed refuses TRACE and accepts GET. rejectsDefaultCredential refuses pairs such as admin:admin, root:root, and guest:guest.
The last block is a checklist in comments: static files with dotfiles denied and directory indexes off, public cloud access blocked at the account, and playground plus sample routes removed in production.
When you talk about it, say this: secure by default, checked at boot, same config path everywhere, only the values change.
Everything below is the demonstration. Same idea, written as code you can run.
'use strict';const assert = require('node:assert/strict');Environment-driven config with validation at startup (fail fast)#
// VULNERABLE:// const port = process.env.PORT || 3000;// const debug = process.env.DEBUG !== 'false'; // undefined => true. fail-open.// const secret = process.env.SESSION_SECRET || 'changeme';// FIXED: parse once, require what matters, throw before listening.function loadConfig(env) { const isProd = env.NODE_ENV === 'production'; const errors = []; if (!['production', 'development', 'test'].includes(env.NODE_ENV)) errors.push('NODE_ENV invalid'); if (!env.SESSION_SECRET || env.SESSION_SECRET.length < 32) errors.push('SESSION_SECRET too short'); if (env.SESSION_SECRET === 'changeme') errors.push('SESSION_SECRET is default'); if (isProd && env.DEBUG === 'true') errors.push('DEBUG on in production'); if (isProd && env.CORS_ORIGIN === '*') errors.push('CORS_ORIGIN wildcard in production'); if (errors.length) throw new Error('config: ' + errors.join('; ')); return Object.freeze({ isProd, debug: env.DEBUG === 'true', // fail-closed: must opt in sessionSecret: env.SESSION_SECRET, corsOrigins: (env.CORS_ORIGIN || '').split(',').filter(Boolean), exposeErrors: !isProd, });}const goodEnv = { NODE_ENV: 'production', SESSION_SECRET: 'x'.repeat(48), CORS_ORIGIN: 'https://app.example.com' };Verified by 6 asserts
assert.equal(loadConfig(goodEnv).debug, false);assert.equal(loadConfig(goodEnv).exposeErrors, false);assert.throws(() => loadConfig({ ...goodEnv, DEBUG: 'true' }), /DEBUG on in production/);assert.throws(() => loadConfig({ ...goodEnv, SESSION_SECRET: 'changeme' }), /default|short/);assert.throws(() => loadConfig({ ...goodEnv, CORS_ORIGIN: '*' }), /wildcard/);assert.throws(() => loadConfig({ SESSION_SECRET: 'x'.repeat(48) }), /NODE_ENV/);Security headers (what helmet sets, as a plain object)#
// VULNERABLE: none of these set; `X-Powered-By: Express` leaks the stack.// FIXED:const securityHeaders = Object.freeze({ 'Content-Security-Policy': "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'", 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload', 'X-Content-Type-Options': 'nosniff', // no MIME sniffing -> blocks some XSS vectors 'X-Frame-Options': 'DENY', // clickjacking (legacy; CSP frame-ancestors is modern) 'Referrer-Policy': 'strict-origin-when-cross-origin', 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()', 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Resource-Policy': 'same-origin', 'Cache-Control': 'no-store', // for authenticated responses});const removeHeaders = ['X-Powered-By', 'Server'];function applyHeaders(res) { for (const [k, v] of Object.entries(securityHeaders)) res.headers[k] = v; for (const k of removeHeaders) delete res.headers[k]; return res;}const res = applyHeaders({ headers: { 'X-Powered-By': 'Express' } });Verified by 4 asserts
assert.equal(res.headers['X-Powered-By'], undefined);assert.match(res.headers['Content-Security-Policy'], /frame-ancestors 'none'/);assert.equal(res.headers['X-Content-Type-Options'], 'nosniff');assert.match(res.headers['Strict-Transport-Security'], /max-age=\d+/);Error handler: no stack traces in prod#
// VULNERABLE:// app.use((err, req, res, next) => res.status(500).send(err.stack)); // paths, versions, SQL// FIXED: generic message out, full detail to logs with a correlation id.function errorResponse(err, config, requestId) { const body = { error: 'Internal Server Error', requestId }; if (config.exposeErrors) body.detail = err.message; // dev only; never err.stack to client return { status: 500, body };}const prodCfg = loadConfig(goodEnv);const devCfg = loadConfig({ ...goodEnv, NODE_ENV: 'development' });const boom = new Error('ECONNREFUSED 10.0.0.7:5432 at /srv/app/db.js:42');Verified by 3 asserts
assert.deepEqual(errorResponse(boom, prodCfg, 'r1').body, { error: 'Internal Server Error', requestId: 'r1' });assert.equal(errorResponse(boom, devCfg, 'r1').body.detail, boom.message);assert.equal(JSON.stringify(errorResponse(boom, prodCfg, 'r1')).includes('10.0.0.7'), false);HTTP method + default-credential checks#
// VULNERABLE: server answers TRACE/OPTIONS everywhere; admin user shipped as admin/admin.// FIXED:const allowedMethods = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']);function methodAllowed(m) { return allowedMethods.has(m); } const knownDefaults = new Set(['admin:admin', 'admin:password', 'root:root', 'guest:guest', 'test:test']);function rejectsDefaultCredential(user, pass) { return !knownDefaults.has(`${user}:${pass}`); }Verified by 4 asserts
assert.equal(methodAllowed('TRACE'), false);assert.equal(methodAllowed('GET'), true);assert.equal(rejectsDefaultCredential('admin', 'admin'), false);assert.equal(rejectsDefaultCredential('admin', 'Zr9!kq2#vL'), true);Directory listing / static root / public bucket (checklist, not code)#
// express.static(path.join(__dirname, 'public'), { dotfiles: 'deny', index: false });// S3: Block Public Access ON at account level; bucket policy explicit; no 'Principal': '*'.// Disable GraphQL introspection + playground in prod; remove sample apps/docs endpoints.See also