Web Security (OWASP Top 10:2025) Module 3 – A02:2025 Security Misconfiguration
Secure defaults: hardening apps and environments
Find the A02:2025 misconfigurations in a deployment and turn hardening into code: a config audit, a drift check and exposure probes after every deploy.
What you will learn
- Identify the A02:2025 misconfigurations of a deployment, such as debug modes, default logins, listings and open internal endpoints
- Write hardening rules as code that fail a weak production config and pass the fixed one
- Compare environments and report drift, so that staging and production differ only in hosts and credentials
- Store secrets as references to a secret store, never in code, config files or client bundles
- Verify each deployment with exposure probes instead of a checklist kept in memory
Before you start
What you’ll learn · Before you start
What you will learn
- Identify the A02:2025 misconfigurations of a deployment, such as debug modes, default logins, listings and open internal endpoints
- Write hardening rules as code that fail a weak production config and pass the fixed one
- Compare environments and report drift, so that staging and production differ only in hosts and credentials
- Store secrets as references to a secret store, never in code, config files or client bundles
- Verify each deployment with exposure probes instead of a checklist kept in memory
Before you start
On this page
In the OWASP Top 10:2025 data, every application tested had at least one security misconfiguration, and the category climbed from fifth place to second. Most of them are settings nobody changed after installing: a debug switch left on, the admin password from the install guide, a folder that lists its files, an error page that prints a stack trace.
You will watch one error handler leak its stack trace in two of three deployments, turn a hardening: Removing or switching off everything a system does not need and setting what remains to its safest working values, by a repeatable process, so that every environment starts locked down the same way. checklist into rules a pipeline runs, compare three environments for configuration drift: A difference that grows between environments that should match, such as a value changed by hand in production but not in staging, so that tests in one no longer predict the other., and finish with probes that prove a deployment is closed. Every check fails on the weak version and passes on the fix.
A misconfiguration is a default that nobody changed
A misconfiguration is rarely a line of code someone wrote badly. It is a setting that kept the value it shipped with, or a feature that stayed installed because nobody switched it off. OWASP’s A02:2025 page lists the usual forms, and ASVS 5.0.0 turns most of them into requirements you can test. Here is what each one hands out once it reaches production:
| Left as installed | What it gives away | ASVS 5.0.0 |
|---|---|---|
| Debug mode | Stack traces, settings, sometimes a console that runs code | 13.4.2 |
| The install guide’s admin login | The admin console, to anyone who read the guide | 6.3.2 |
| Directory listings | Every file in a folder, backups included | 13.4.3 |
| The TRACE method | Requests echoed back to their sender | 13.4.4 |
| Open API docs and monitoring | A map of every route and the app’s internal state | 13.4.5 |
| Version banners | The exact version to match against published flaws | 13.4.6 |
A .git folder or backup files on the server |
Source code, its history, sometimes data | 13.4.1, 13.4.7 |
OWASP counted 719,084 occurrences across the 16 weaknesses it maps to the category, with an average incidence of
3.00 % and a maximum of 27.70 %. Some unsafe defaults are there for convenience, and they are documented: nginx puts
its version in the Server header and on its error pages unless server_tokens is switched off, and Express adds
X-Powered-By: Express to responses until the app calls app.disable('x-powered-by'). Neither is a bug. Each is a
value someone has to change in every deployment, and the deployment where nobody did is the one that gives itself
away.
That is the difference between a secure default: A setting whose value is safe when nobody sets it, such as directory listing off until someone switches it on. A forgotten step then costs a feature instead of exposing something. and a secure configuration. A setting whose safe value has to be switched on will be missing somewhere, sooner or later: in a new region, a rebuilt container, a test environment someone made public. A setting that is safe when nobody touches it fails in the other direction, so a forgotten step costs a feature, not a breach.
Key idea
Most misconfigurations are defaults nobody changed: prefer components whose defaults are safe, and treat every other default as a value you set on purpose, in every environment.
Checkpoint
Check: Misconfigurations are unchanged defaults
Debug pages and detailed errors hand out your internals
The most common way internals leak is an error page. Frameworks show detail while you develop and expect you to switch it off for production, and their documentation says how much is at stake:
- Express: the built-in error handler writes the stack trace into the response unless the app runs in the
production environment. The environment comes from the variable
NODE_ENV, and when it is not set Express assumesdevelopment. - Django:
DEBUGmust never be on in production, because it shows excerpts of source code, local variables, settings and the libraries used. - Flask: the debugger of the development server lets anyone who reaches it run Python code from the browser. It is protected by a PIN, which the documentation says not to rely on.
So the safe state of a whole app can hang on one environment variable being present and spelled right. This program fails one invoice route in three deployments, once with a handler that follows that default and once with a handler whose default is safe:
// One route of a shop fails, and two error handlers answer it in three deployments. The weak handler follows a
// common framework default: it sends the stack trace unless NODE_ENV is exactly "production". The safe handler keeps
// the stack in the server's log and answers with a reference to quote, unless a developer switches detail on locally.
function loadInvoice(id) {
const err = new Error(`invoice ${id} not found in table invoices_v2`);
// A fixed stack, so every run prints the same lines (a real stack names the files of your own server).
err.stack = [
`Error: ${err.message}`,
' at loadInvoice (/srv/shop/src/invoices.js:41:11)',
' at handler (/srv/shop/src/routes/invoice.js:12:5)',
' at dispatch (/srv/shop/node_modules/router-kit/4.1.2/index.js:208:9)',
].join('\n');
throw err;
}
// Weak: detail is on unless someone set exactly the right value.
function weakHandler(err, env) {
if (env.NODE_ENV !== 'production') return err.stack;
return 'Internal Server Error';
}
// Safe: detail is off unless a developer switches it on, on their own machine.
const serverLog = [];
let lastRef = 1041;
function safeHandler(err, env) {
const ref = `E-${++lastRef}`;
serverLog.push(`${ref} ${err.stack.split('\n')[0]}`);
if (env.APP_ENV === 'local' && env.SHOW_ERRORS === 'yes') return err.stack;
return `Something went wrong. Quote ${ref} to support.`;
}
// The regression check: a body a client receives must hold no stack frame, server path or package version.
const leaks = (body) => /\n\s+at |\/srv\/|node_modules|\d+\.\d+\.\d+/.test(body);
const deployments = [
{ name: 'NODE_ENV not set', env: {} },
{ name: 'NODE_ENV=prod (typo)', env: { NODE_ENV: 'prod' } },
{ name: 'NODE_ENV=production', env: { NODE_ENV: 'production' } },
];
const respond = (handler, env) => {
try {
loadInvoice(77);
} catch (err) {
return handler(err, env);
}
};
const firstLine = (body) => (body.includes('\n') ? `stack trace, ${body.split('\n').length} lines` : body);
let weakLeaks = 0;
let safeLeaks = 0;
for (const d of deployments) {
const weak = respond(weakHandler, d.env);
const safe = respond(safeHandler, d.env);
if (leaks(weak)) weakLeaks++;
if (leaks(safe)) safeLeaks++;
console.log(d.name);
console.log(` weak sends: ${firstLine(weak)}${leaks(weak) ? ' <- LEAK' : ''}`);
console.log(` safe sends: ${firstLine(safe)}${leaks(safe) ? ' <- LEAK' : ''}`);
}
console.log('\nWhat the weak handler sent with NODE_ENV not set:');
console.log(respond(weakHandler, {}));
console.log(`\nLeak check: weak ${weakLeaks} of 3 deployments ${weakLeaks ? 'FAIL' : 'pass'}, safe ${safeLeaks} of 3 ${safeLeaks ? 'FAIL' : 'pass'}`);
console.log(`Server log of the safe handler: ${serverLog.length} lines, first: ${serverLog[0]}`); Output
NODE_ENV not set
weak sends: stack trace, 4 lines <- LEAK
safe sends: Something went wrong. Quote E-1042 to support.
NODE_ENV=prod (typo)
weak sends: stack trace, 4 lines <- LEAK
safe sends: Something went wrong. Quote E-1043 to support.
NODE_ENV=production
weak sends: Internal Server Error
safe sends: Something went wrong. Quote E-1044 to support.
What the weak handler sent with NODE_ENV not set:
Error: invoice 77 not found in table invoices_v2
at loadInvoice (/srv/shop/src/invoices.js:41:11)
at handler (/srv/shop/src/routes/invoice.js:12:5)
at dispatch (/srv/shop/node_modules/router-kit/4.1.2/index.js:208:9)
Leak check: weak 2 of 3 deployments FAIL, safe 0 of 3 pass
Server log of the safe handler: 3 lines, first: E-1042 Error: invoice 77 not found in table invoices_v2
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node error_modes.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Look at the first two deployments. With NODE_ENV not set, as happens when a container image or a service file
forgets it, and with the typo prod, the weak handler sent all four lines of the stack: the server’s file layout,
line numbers, and the router package with its version, 4.1.2, ready to look up in advisory databases. Only the
deployment that got the exact value right was safe. The safe handler sent the same short message in all three and
kept the stack in the server’s log under a reference, E-1042 to E-1044, that support can look up. Its detail mode
needs two values that only a developer’s machine sets, so a missing or misspelled value turns detail off. Step
through the first deployment with each handler:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The request. A customer opens invoice 77. The request reaches the shop's server like any other.
- The route fails. The invoice is missing, so the route throws. The error carries a stack of four lines with the server's file paths, line numbers and the router package at version 4.1.2.
- The weak handler. The weak handler checks whether NODE_ENV is exactly production. It is not set at all, so the handler treats the deployment as development and sends the stack.
- What the customer saw. Anyone who triggers the error now knows the server's file layout and a package version to look up in advisory databases. Nothing in the log says the detail went out.
- The safe handler. Same request, same deployment, the safe handler. It writes the stack to the server log under the reference E-1042 first.
- What the customer sees now. The customer gets a short message with the reference and nothing else. A missing or misspelled variable cannot change that, because detail needs two values that only a developer's machine has.
- Support finds it. When the customer quotes E-1042, support finds the full stack in the log. The detail reaches the people who fix the bug and no one else.
The leak check at the end of the program is the test to keep: it fails on any body with a stack frame, a server path or a package version, so a deployment that would show detail fails before it ships. OWASP asks for the same safety net in one place: a central configuration that intercepts overly detailed error messages, as a backup for every route that forgets its own handling.
Detail on, unless the value is exactly right
if (env.NODE_ENV !== 'production') send(err.stack) treats a missing value, a typo and every new environment as
development. Turn detail on only when development is asked for explicitly, as the safe handler does with
APP_ENV=local and SHOW_ERRORS=yes, and keep the leak check in the test suite.
Checkpoint
Check: Error detail and debug modes
A hardening checklist holds only when it runs as code
A checklist on a wiki page is followed on launch day and forgotten by the third new environment. The first step of OWASP’s prevention advice is a hardening process you can repeat to stand up a locked-down environment quickly, and another is an automated check that the settings are in place in every environment. Both point to the same tool: rules that a program applies to the config. Each rule below reads one setting, names the requirement it checks and says what is wrong, and the gate fails on any finding:
// A hardening checklist written as rules: each rule reads the config, names the requirement it checks (ASVS 5.0.0
// or a CWE) and says what is wrong. A pipeline runs the same rules on every change to the config and stops the
// deploy on any finding. Here they run on a weak production config and on its fix.
const DEFAULT_LOGINS = new Set(['admin:admin', 'admin:password', 'admin:changeme', 'root:root']);
const INTERNAL_ROUTES = ['/actuator', '/api/docs', '/debug'];
const RULES = [
{ id: 'debug-off', ref: 'ASVS 13.4.2', check: (c) => c.debug && 'debug mode is on' },
{ id: 'error-detail', ref: 'ASVS 13.4.2', check: (c) => c.errors.showDetails && 'error pages show detail' },
{ id: 'default-login', ref: 'ASVS 6.3.2', check: (c) => DEFAULT_LOGINS.has(`${c.admin.user}:${c.admin.password}`) && 'the install guide login works' },
{ id: 'secret-in-file', ref: 'CWE-260', check: (c) => !c.admin.password.startsWith('secret:') && 'a password is written in the file' },
{ id: 'dir-listing', ref: 'ASVS 13.4.3', check: (c) => c.http.directoryListing && 'folders list their files' },
{ id: 'trace-on', ref: 'ASVS 13.4.4', check: (c) => c.http.traceMethod && 'TRACE is answered' },
{
id: 'internals-open',
ref: 'ASVS 13.4.5',
check: (c) => {
const open = INTERNAL_ROUTES.filter((r) => c.routes[r] === 'public');
return open.length > 0 && `open to anyone: ${open.join(', ')}`;
},
},
{ id: 'version-banner', ref: 'ASVS 13.4.6', check: (c) => (/\d/.test(c.http.serverHeader) || c.http.poweredBy) && 'responses name software versions' },
{ id: 'cors-any-origin', ref: 'CWE-942', check: (c) => c.cors.origins.includes('*') && c.cors.credentials && 'every origin allowed, with cookies' },
{ id: 'cookie-insecure', ref: 'CWE-614', check: (c) => !c.cookies.secure && 'session cookie goes over http' },
];
const weak = {
env: 'production',
debug: true,
errors: { showDetails: true },
admin: { user: 'admin', password: 'admin' },
http: { directoryListing: true, traceMethod: true, serverHeader: 'nginx/1.27.4', poweredBy: true },
routes: { '/admin': 'office-vpn', '/actuator': 'public', '/api/docs': 'public' },
cors: { origins: ['*'], credentials: true },
cookies: { secure: false, httpOnly: true },
};
const fixed = {
env: 'production',
debug: false,
errors: { showDetails: false },
admin: { user: 'ops-asha', password: 'secret:admin-password' },
http: { directoryListing: false, traceMethod: false, serverHeader: 'web', poweredBy: false },
routes: { '/admin': 'office-vpn', '/actuator': 'office-vpn', '/api/docs': 'office-vpn' },
cors: { origins: ['https://shop.example'], credentials: true },
cookies: { secure: true, httpOnly: true },
};
function audit(config) {
return RULES.map((r) => ({ ...r, problem: r.check(config) })).filter((r) => r.problem);
}
const gate = {};
for (const [name, config] of [['weak', weak], ['fixed', fixed]]) {
const findings = audit(config);
gate[name] = findings.length === 0 ? 'pass' : 'FAIL';
console.log(`${name} config (${config.env}): ${findings.length} findings`);
for (const f of findings) console.log(` ${f.id.padEnd(17)}${f.ref.padEnd(13)}${f.problem}`);
}
console.log(`Gate: weak ${gate.weak}, fixed ${gate.fixed}`); Output
weak config (production): 10 findings debug-off ASVS 13.4.2 debug mode is on error-detail ASVS 13.4.2 error pages show detail default-login ASVS 6.3.2 the install guide login works secret-in-file CWE-260 a password is written in the file dir-listing ASVS 13.4.3 folders list their files trace-on ASVS 13.4.4 TRACE is answered internals-open ASVS 13.4.5 open to anyone: /actuator, /api/docs version-banner ASVS 13.4.6 responses name software versions cors-any-origin CWE-942 every origin allowed, with cookies cookie-insecure CWE-614 session cookie goes over http fixed config (production): 0 findings Gate: weak FAIL, fixed pass
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node config_audit.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The weak config broke all ten rules, and its fix broke none. Four of the findings deserve a closer look. default-login
and secret-in-file fired on the same line, admin:admin: one rule asks whether the login comes from an install
guide, the other whether any password is written in the file at all, and the fix answers both with a new account
and secret:admin-password, a reference the deploy resolves from the secret store. internals-open names the
routes it found, because a finding should say exactly what to change. And version-banner catches a version in
Server and the mere presence of X-Powered-By.
Break the fixed config
Press Edit, set secure in the fixed config’s cookies to false and run the program again. The gate fails with
one finding, cookie-insecure, which is what a pipeline shows on the change that caused it.
In a pipeline the rules run in three places: on every change to a config file, before it merges; on each environment’s final config, before the deploy; and on a schedule against what is running, so that a value changed by hand on a server shows up the next morning. Rules like these are often called policy as code: Rules about configuration written as a program that a pipeline runs on every change and every deploy, each naming the requirement it checks, instead of a checklist someone has to remember.. Where something cannot be checked by a program, OWASP asks for a manual check at least once a year.
Key idea
Hardening holds when its checklist runs as rules on every change and every deploy, each rule naming its requirement and exactly what to change.
Checkpoint
Check: Hardening rules as code
Environments should differ only in hosts and credentials
A setting tested in staging protects production only if production has the same setting. OWASP asks for development, QA and production to be configured identically, each with its own credentials. In practice that means one template of every security setting, environment files that may change only host names and references to secrets, and a short written list of what development may relax. This program checks three environments against those rules before a deploy:
"""Three environments of one shop, compared setting by setting before a deploy.
Staging and production must match in every security setting, so that what was tested is what runs. Development may
differ only where the team wrote the exception down. Host names differ by design but are never a wildcard, and every
credential is a reference to the secret store, different in each environment. A secret's value is never printed.
"""
ENVS = {
"development": {
"debug": True, "error_detail": True, "cookie_secure": False, "cookie_samesite": "Lax",
"allowed_hosts": "localhost", "log_level": "debug", "admin_route": "local",
"db_password": "secret:dev/db", "signing_key": "secret:dev/signing",
},
"staging": {
"debug": False, "error_detail": False, "cookie_secure": True, "cookie_samesite": "Lax",
"allowed_hosts": "staging.shop.example", "log_level": "info", "admin_route": "vpn",
"db_password": "secret:stg/db", "signing_key": "secret:stg/signing",
},
"production": {
"debug": False, "error_detail": False, "cookie_secure": True, "cookie_samesite": "None",
"allowed_hosts": "*", "log_level": "debug", "admin_route": "vpn",
"db_password": "Pa55word-2", "signing_key": "secret:prd/signing",
},
}
DEV_EXCEPTIONS = {"debug", "error_detail", "cookie_secure", "log_level", "admin_route"}
PER_ENVIRONMENT = {"allowed_hosts"}
CREDENTIALS = {"db_password", "signing_key"}
def verdict(name, dev, stg, prd):
if name in CREDENTIALS:
literal = [env for env, v in (("dev", dev), ("staging", stg), ("prod", prd)) if not v.startswith("secret:")]
if literal:
return "LITERAL secret: " + ", ".join(literal)
return "ok, three references" if len({dev, stg, prd}) == 3 else "SHARED secret"
if name in PER_ENVIRONMENT:
return "WILDCARD host in prod" if prd == "*" else "ok, one host each"
if stg != prd:
return "DRIFT: prod vs staging"
if dev != stg:
return "ok, dev exception" if name in DEV_EXCEPTIONS else "DRIFT: dev vs staging"
return "ok"
def shown(name, value):
if name in CREDENTIALS:
return "secret:..." if value.startswith("secret:") else "(literal)"
return str(value)[:9]
print(f"{'setting':<17}{'dev':<11}{'staging':<11}{'prod':<11}verdict")
problems = 0
for name in ENVS["staging"]:
dev, stg, prd = (ENVS[e][name] for e in ("development", "staging", "production"))
v = verdict(name, dev, stg, prd)
problems += not v.startswith("ok")
print(f"{name:<17}" + "".join(f"{shown(name, x):<11}" for x in (dev, stg, prd)) + v)
print(f"{problems} problems: the production deploy {'stops' if problems else 'may go ahead'}.") Output
setting dev staging prod verdict debug True False False ok, dev exception error_detail True False False ok, dev exception cookie_secure False True True ok, dev exception cookie_samesite Lax Lax None DRIFT: prod vs staging allowed_hosts localhost staging.s * WILDCARD host in prod log_level debug info debug DRIFT: prod vs staging admin_route local vpn vpn ok, dev exception db_password secret:... secret:... (literal) LITERAL secret: prod signing_key secret:... secret:... secret:... ok, three references 4 problems: the production deploy stops.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 env_parity.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Four problems, and staging’s tests would have caught none of them. Production’s session cookie has SameSite=None
where staging’s has Lax, so a form posted from another site arrives without the cookie in staging and with it in
production. Production logs at debug level, a setting staging never ran with. Its allowed host is a wildcard, which
Django’s checklist says leaves you to validate the Host header yourself. And its database password is written in
the file. Development’s differences are all on the exception list. The program printed only that a literal secret
exists, never its value, because a report that shows a secret leaks it into every log that keeps the report. Step
through the release this check guards:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Build once. The release is built once. The artifact that ran in staging is the one that will run in production, so the only thing left that can differ is configuration.
- One template. Every security setting lives in one template, such as debug off, generic error pages, Secure cookies with SameSite=Lax and the admin routes behind the VPN.
- Development relaxes on paper. The development file sets localhost as its host, and may switch on only what the written exception list names, such as debug output and cookies without Secure.
- Staging and production. The staging and production files may change two things, their host names and the references to their own secrets, such as secret:stg/db and secret:prd/db.
- The check runs. Before the deploy, the check compares the rendered settings of staging and production one by one and looks for any secret written as a literal value.
- A problem stops it. This release has SameSite=None and debug logging in production, a wildcard host and a literal database password. The deploy stops, and the fix goes into the production file.
- Clean, then deploy. With the production file fixed, the check finds nothing. The deploy goes ahead, and production runs exactly the settings staging tested.
Key idea
Staging and production should differ only in host names and references to their own secrets, and development only by a written list of exceptions.
Credentials are the one thing that must differ, and they belong in a secret store, not in the config, the code or a
bundle. Anything a browser receives is public: Vite, for one, writes every variable whose name starts with VITE_
into the client code at build time and warns that such variables must not hold API keys. Environment variables are
better than files in the repository, and still not private: CWE-526 notes that child processes can reach them and
that their values can end up in messages, headers and logs. ASVS 5.0.0 requirement 13.3.1 asks for a secrets manager at
level 2, and the module on cryptographic failures covers keeping and rotating secrets.
A secret in the client bundle
Naming a key VITE_PAYMENT_KEY so the front end can call a payment service sends the key to every visitor. Keep
the key on your server: the front end calls your server, and your server calls the service.
Checkpoint
Check: Environment parity and secret references
Prove every deployment with exposure probes
The config says what should happen; only the running deployment shows what does. ASVS 5.0.0 section 13.4,
unintended information leakage, is a list that turns almost line by line into requests you can send to your own
app. This program sends nine of them to two deployments of the same app: one hardened from memory, where someone
removed the .git folder before launch, and one hardened by a script:
// Probes that every deployment of your own app must pass, each tied to a requirement of ASVS 5.0.0 section 13.4.
// `send(method, path)` is whatever reaches the deployment. Here it is two simulated servers; in a pipeline it is a
// function that calls fetch() on your own staging or production address right after each deploy.
const PROBES = [
{ method: 'GET', path: '/.git/config', ok: [404], asvs: '13.4.1' },
{ method: 'GET', path: '/debug/vars', ok: [404], asvs: '13.4.2' },
{ method: 'GET', path: '/uploads/', ok: [403, 404], asvs: '13.4.3' },
{ method: 'TRACE', path: '/', ok: [405], asvs: '13.4.4' },
{ method: 'GET', path: '/actuator/env', ok: [401, 403, 404], asvs: '13.4.5' },
{ method: 'GET', path: '/api/docs', ok: [401, 403, 404], asvs: '13.4.5' },
{ method: 'GET', path: '/', ok: [200], asvs: '13.4.6', quiet: true },
{ method: 'GET', path: '/.env', ok: [404], asvs: '13.4.7' },
{ method: 'GET', path: '/backup/shop.sql.gz', ok: [404], asvs: '13.4.7' },
];
// Hardened from memory: someone removed the .git folder before launch, and nothing else changed.
function rememberedServer(method, path) {
const headers = { server: 'nginx/1.27.4', 'x-powered-by': 'Express' };
if (method === 'TRACE') return { status: 200, headers };
const served = ['/', '/debug/vars', '/uploads/', '/actuator/env', '/api/docs', '/.env', '/backup/shop.sql.gz'];
return { status: served.includes(path) ? 200 : 404, headers };
}
// Hardened from a script: only what the app serves on purpose, internals behind sign-in.
function scriptedServer(method, path) {
const headers = { server: 'web' };
if (!['GET', 'HEAD', 'POST'].includes(method)) return { status: 405, headers };
if (path === '/') return { status: 200, headers };
if (path === '/actuator/env' || path === '/api/docs') return { status: 401, headers };
return { status: 404, headers };
}
// A version banner is a digit in Server, or any X-Powered-By at all.
const namesVersions = (h) => /\d/.test(h.server ?? '') || 'x-powered-by' in h;
function check(probe, send) {
const res = send(probe.method, probe.path);
if (!probe.ok.includes(res.status)) return { pass: false, shown: `${res.status} FAIL` };
if (probe.quiet && namesVersions(res.headers)) return { pass: false, shown: 'versions FAIL' };
return { pass: true, shown: `${res.status} pass` };
}
console.log(`${'probe'.padEnd(26)}${'ASVS'.padEnd(8)}${'remembered'.padEnd(15)}scripted`);
const failed = { remembered: 0, scripted: 0 };
for (const p of PROBES) {
const a = check(p, rememberedServer);
const b = check(p, scriptedServer);
failed.remembered += !a.pass;
failed.scripted += !b.pass;
const label = `${p.method} ${p.path}${p.quiet ? ' (headers)' : ''}`;
console.log(`${label.padEnd(26)}${p.asvs.padEnd(8)}${a.shown.padEnd(15)}${b.shown}`);
}
console.log(`Failed probes: remembered ${failed.remembered} of ${PROBES.length}, scripted ${failed.scripted} of ${PROBES.length}`); Output
probe ASVS remembered scripted GET /.git/config 13.4.1 404 pass 404 pass GET /debug/vars 13.4.2 200 FAIL 404 pass GET /uploads/ 13.4.3 200 FAIL 404 pass TRACE / 13.4.4 200 FAIL 405 pass GET /actuator/env 13.4.5 200 FAIL 401 pass GET /api/docs 13.4.5 200 FAIL 401 pass GET / (headers) 13.4.6 versions FAIL 200 pass GET /.env 13.4.7 200 FAIL 404 pass GET /backup/shop.sql.gz 13.4.7 200 FAIL 404 pass Failed probes: remembered 8 of 9, scripted 0 of 9
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node exposure_probes.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The deployment hardened from memory passed the one probe its team remembered and failed the other eight: a debug
page, a listing of the uploads folder, TRACE, monitoring and API documentation open to anyone, version banners, an
environment file and a database backup. The scripted deployment passed all nine. Two choices in the probes are
deliberate. /actuator/env and /api/docs may answer 401, 403 or 404, because ASVS 13.4.5 asks only that such
endpoints are not exposed unless that is intended, and behind a sign-in they are closed to strangers. And the probe
of / expects a 200 and then reads the headers: a 200 that names nginx/1.27.4 still fails.
Key idea
Prove each deployment by asking for what must not be there: a probe that expects 404 for /.git/config takes
milliseconds and stops the deploy that would leak it.
In a pipeline, send is a function that calls fetch() on your own staging or production address, and the probes
run right after each deploy, as a smoke test: A short set of checks run right after a deployment to show that it works and is closed, such as asking for /.git/config and expecting 404.. Only ever point them at systems you own or
have written permission to test.
A probe only finds what it asks for
Press Edit, add { method: 'GET', path: '/config.json', ok: [404], asvs: '13.4.7' } to PROBES and run it. Both
deployments pass, because neither serves that file. Build your list from your own stack: its framework’s debug
routes, your server’s status pages, the backup names your scripts write.
Checkpoint
Check: Exposure probes after each deploy
Practise: build a deployment gate
Two functions make a small gate for a deploy, and each uses a rule of this lesson. The first audits one environment’s config with the written exceptions of development; the second compares three environments and reports drift and literal secrets. Both start from code that fails its sample tests, the tests run in your browser, and a hint waits behind each step for when you are stuck:
Code lab · 2 steps
Build a deployment gate
Audit one environment's config with its written exceptions, then compare three environments for drift and literal secrets.
- Audit one environment's config, with its written exceptions To do
- Report drift and literal secrets across three environments To do
Exercise · Easy · JavaScript, Python
Audit one environment's config, with its written exceptions
Write audit(config) in audit.mjs (Python: audit(config) in audit.py). config describes one environment of an app, and the function returns the ids of the rules it breaks, sorted alphabetically (an empty list when it breaks none):
- **
debug**: debug is on outsidedevelopment. - **
dir-listing**: directory listing is on, in any environment. - **
trace**: the TRACE method is on outsidedevelopment. - **
cookie**: cookies are sent withoutSecureand the host is notlocalhost. - **
default-password**: the admin password is one ofDEFAULT_PASSWORDS, the empty password included. - **
exposed**: the list of exposed paths holds/.gitor/.env.
In JavaScript config is { env, host, debug, directoryListing, traceMethod, cookieSecure, adminPassword, exposed }; in Python it is a dict with the keys env, host, debug, directory_listing, trace_method, cookie_secure, admin_password and exposed. env is 'development', 'staging' or 'production', and staging is held to the rules of production.
JavaScript · Starter code · audit.mjs
/** Passwords from install guides and quick-start pages, the empty one included. */
export const DEFAULT_PASSWORDS = new Set(['admin', 'password', 'changeme', '123456', '']);
/**
* The ids of the rules one environment's config breaks, sorted: cookie, debug, default-password, dir-listing,
* exposed, trace. config: { env, host, debug, directoryListing, traceMethod, cookieSecure, adminPassword, exposed }
*/
export function audit(config) {
// Starter: checks debug only, and in every environment.
return config.debug ? ['debug'] : [];
} The sample tests · audit.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { audit } from './audit.mjs';
const prod = (over) => ({
env: 'production', host: 'shop.example', debug: false, directoryListing: false, traceMethod: false,
cookieSecure: true, adminPassword: 'secret:admin-password', exposed: [], ...over,
});
test('a hardened production config breaks no rule', () => {
assert.deepEqual(audit(prod({})), []);
assert.deepEqual(audit(prod({ exposed: ['/uploads', '/robots.txt'] })), [], 'paths served on purpose are not findings');
});
test('a weak production config breaks all six rules', () => {
const weak = prod({ debug: true, directoryListing: true, traceMethod: true, cookieSecure: false, adminPassword: 'admin', exposed: ['/uploads', '/.git'] });
assert.deepEqual(audit(weak), ['cookie', 'debug', 'default-password', 'dir-listing', 'exposed', 'trace']);
});
test('development on localhost may switch on debug, TRACE and cookies without Secure', () => {
const dev = prod({ env: 'development', host: 'localhost', debug: true, traceMethod: true, cookieSecure: false });
assert.deepEqual(audit(dev), []);
assert.deepEqual(audit({ ...dev, directoryListing: true }), ['dir-listing'], 'a directory listing is a finding everywhere');
assert.deepEqual(audit({ ...dev, host: 'dev.shop.example' }), ['cookie'], 'the cookie exception is for localhost only');
});
test('staging is held to the rules of production', () => {
assert.deepEqual(audit(prod({ env: 'staging', host: 'staging.shop.example', debug: true, cookieSecure: false })), ['cookie', 'debug']);
assert.deepEqual(audit(prod({ adminPassword: '' })), ['default-password'], 'an empty password is a default too');
assert.deepEqual(audit(prod({ exposed: ['/.env'] })), ['exposed']);
}); Python · Starter code · audit.py
# Passwords from install guides and quick-start pages, the empty one included.
DEFAULT_PASSWORDS = {"admin", "password", "changeme", "123456", ""}
def audit(config):
"""The ids of the rules one environment's config breaks, sorted: cookie, debug, default-password, dir-listing,
exposed, trace. config is a dict with env, host, debug, directory_listing, trace_method, cookie_secure,
admin_password and exposed."""
# Starter: checks debug only, and in every environment.
return ["debug"] if config["debug"] else [] The sample tests · test_audit.py
from audit import audit
def prod(**over):
config = {
"env": "production", "host": "shop.example", "debug": False, "directory_listing": False,
"trace_method": False, "cookie_secure": True, "admin_password": "secret:admin-password", "exposed": [],
}
config.update(over)
return config
def test_a_hardened_production_config_breaks_no_rule():
assert audit(prod()) == []
assert audit(prod(exposed=["/uploads", "/robots.txt"])) == [], "paths served on purpose are not findings"
def test_a_weak_production_config_breaks_all_six_rules():
weak = prod(debug=True, directory_listing=True, trace_method=True, cookie_secure=False, admin_password="admin",
exposed=["/uploads", "/.git"])
assert audit(weak) == ["cookie", "debug", "default-password", "dir-listing", "exposed", "trace"]
def test_development_on_localhost_may_switch_on_debug_trace_and_insecure_cookies():
dev = prod(env="development", host="localhost", debug=True, trace_method=True, cookie_secure=False)
assert audit(dev) == []
assert audit({**dev, "directory_listing": True}) == ["dir-listing"], "a directory listing is a finding everywhere"
assert audit({**dev, "host": "dev.shop.example"}) == ["cookie"], "the cookie exception is for localhost only"
def test_staging_is_held_to_the_rules_of_production():
assert audit(prod(env="staging", host="staging.shop.example", debug=True, cookie_secure=False)) == ["cookie", "debug"]
assert audit(prod(admin_password="")) == ["default-password"], "an empty password is a default too"
assert audit(prod(exposed=["/.env"])) == ["exposed"] A hint
Work out one fact first, whether this is development (env === 'development'), and use it in the two rules that have an exception: debug and TRACE. The cookie rule has its own exception, the host localhost, which has nothing to do with the environment's name. Push each id you find into a list and sort it once at the end.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS, Pyodide): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: JavaScript (about 0.6 MB) or Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Easy · JavaScript, Python
Report drift and literal secrets across three environments
Write drift(envs) in drift.mjs (Python: drift(envs) in drift.py). envs holds three environments, development, staging and production, each a set of settings with the same keys, host among them. Return the problems as a sorted list of strings:
- **
'drift:<setting>'** when staging and production differ in a setting that is neither the host nor a secret. - **
'dev:<setting>'** when development differs from staging in a setting that is neither the host nor a secret, and the setting is not inDEV_EXCEPTIONS. - **
'literal:<environment>:<setting>'** for every secret, in any environment, whose value does not start withsecret:.
SECRETS names the settings that hold secrets; the host differs by design and is never reported. Never put a secret's value into a problem: the name of the setting is enough.
JavaScript · Starter code · drift.mjs
/** Settings that hold secrets: a reference to the secret store, different in every environment. */
export const SECRETS = ['db_password', 'signing_key'];
/** What development may set differently from staging, by the team's written list. */
export const DEV_EXCEPTIONS = ['debug', 'error_detail', 'cookie_secure', 'log_level'];
/**
* The problems of three environments, sorted: 'drift:<setting>' (staging and production differ), 'dev:<setting>'
* (development differs from staging outside DEV_EXCEPTIONS) and 'literal:<environment>:<setting>' (a secret that does
* not start with 'secret:'). envs: { development, staging, production }, each with the same keys, host included.
*/
export function drift(envs) {
// Starter: compares staging with production for every key, the host and the secrets included.
const problems = [];
for (const key of Object.keys(envs.staging)) {
if (envs.staging[key] !== envs.production[key]) problems.push(`drift:${key}`);
}
return problems.sort();
} The sample tests · drift.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { drift } from './drift.mjs';
const base = {
host: 'staging.shop.example', debug: false, cookie_samesite: 'Lax', log_level: 'info', admin_route: 'vpn',
db_password: 'secret:stg/db', signing_key: 'secret:stg/signing',
};
const three = (dev, prod) => ({
development: { ...base, host: 'localhost', db_password: 'secret:dev/db', signing_key: 'secret:dev/signing', ...dev },
staging: { ...base },
production: { ...base, host: 'shop.example', db_password: 'secret:prd/db', signing_key: 'secret:prd/signing', ...prod },
});
test('environments that differ only in hosts and secret references have no problem', () => {
assert.deepEqual(drift(three({}, {})), []);
});
test('production drifting from staging, and a literal secret, as in the lesson', () => {
const envs = three({ debug: true, log_level: 'debug' }, { cookie_samesite: 'None', log_level: 'debug', db_password: 'Pa55word-2' });
assert.deepEqual(drift(envs), ['drift:cookie_samesite', 'drift:log_level', 'literal:production:db_password']);
});
test('development may differ only by the written exceptions', () => {
assert.deepEqual(drift(three({ debug: true, admin_route: 'local' }, {})), ['dev:admin_route']);
});
test('a literal secret counts in every environment, and its value is never reported', () => {
const problems = drift(three({ signing_key: 'dev-key-123' }, {}));
assert.deepEqual(problems, ['literal:development:signing_key']);
assert.ok(!problems.join(' ').includes('dev-key-123'), 'the value of a secret stays out of the report');
}); Python · Starter code · drift.py
# Settings that hold secrets: a reference to the secret store, different in every environment.
SECRETS = ["db_password", "signing_key"]
# What development may set differently from staging, by the team's written list.
DEV_EXCEPTIONS = ["debug", "error_detail", "cookie_secure", "log_level"]
def drift(envs):
"""The problems of three environments, sorted: 'drift:<setting>' (staging and production differ),
'dev:<setting>' (development differs from staging outside DEV_EXCEPTIONS) and 'literal:<environment>:<setting>'
(a secret that does not start with 'secret:'). envs maps development, staging and production to their settings,
each with the same keys, host included."""
# Starter: compares staging with production for every key, the host and the secrets included.
problems = []
for key in envs["staging"]:
if envs["staging"][key] != envs["production"][key]:
problems.append(f"drift:{key}")
return sorted(problems) The sample tests · test_drift.py
from drift import drift
BASE = {
"host": "staging.shop.example", "debug": False, "cookie_samesite": "Lax", "log_level": "info", "admin_route": "vpn",
"db_password": "secret:stg/db", "signing_key": "secret:stg/signing",
}
def three(dev=None, prod=None):
return {
"development": {**BASE, "host": "localhost", "db_password": "secret:dev/db", "signing_key": "secret:dev/signing", **(dev or {})},
"staging": dict(BASE),
"production": {**BASE, "host": "shop.example", "db_password": "secret:prd/db", "signing_key": "secret:prd/signing", **(prod or {})},
}
def test_environments_that_differ_only_in_hosts_and_secret_references_have_no_problem():
assert drift(three()) == []
def test_production_drifting_from_staging_and_a_literal_secret_as_in_the_lesson():
envs = three({"debug": True, "log_level": "debug"}, {"cookie_samesite": "None", "log_level": "debug", "db_password": "Pa55word-2"})
assert drift(envs) == ["drift:cookie_samesite", "drift:log_level", "literal:production:db_password"]
def test_development_may_differ_only_by_the_written_exceptions():
assert drift(three({"debug": True, "admin_route": "local"})) == ["dev:admin_route"]
def test_a_literal_secret_counts_in_every_environment_and_its_value_is_never_reported():
problems = drift(three({"signing_key": "dev-key-123"}))
assert problems == ["literal:development:signing_key"]
assert "dev-key-123" not in " ".join(problems), "the value of a secret stays out of the report" A hint
Loop over the keys of one environment and sort each key into one of three kinds first: the host (skip it), a secret (check its value in all three environments) or an ordinary setting (compare staging with production, then development with staging). Collect the strings in one list and sort it at the end.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS, Pyodide): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: JavaScript (about 0.6 MB) or Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then test what you know about defaults, drift and probes. The questions begin where your answers have been weakest:
Practice that adapts to you
Practise this lesson
11 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what does “secure by default” mean for configuration, and why does it matter?
A setting is secure by default when its value is safe if nobody touches it: directory listing off until someone
switches it on, an error handler that hides detail unless a developer enables it on their own machine. Insecure
defaults work the other way round. nginx sends its version until server_tokens is switched off, and Express sends
X-Powered-By until the app disables it. The difference matters because configuration is copied: every new
environment, container image and service file starts from the defaults, and the one where someone forgot the change
is the one that leaks. So choose components whose defaults are safe, wrap the rest in a template that sets the safe
values, and check the running result with tests, because a checklist kept in memory is forgotten by the third
environment.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to senior
After a routine deploy, a customer sends a screenshot of an error page that shows a stack trace with your server's file paths. The code has not changed in weeks. How do you find the cause, and how do you make sure it cannot happen again?
-
Mid to staff
Teams can create a new environment from a template in ten minutes. Design the checks that keep every new environment as locked down as production without slowing the teams down.
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What is a typical security misconfiguration, and how does a secure default differ from a secure configuration?
A setting that kept the value it shipped with: debug mode left on, the install guide's admin login, directory listings, TRACE, open API docs or monitoring, version banners, a .git folder or backups on the server. A secure default is safe if nobody touches it, so a forgotten step costs a feature; a setting whose safe value must be switched on will be missing somewhere sooner or later.
-
Name two documented defaults that reveal the stack, and the switch for each.
nginx puts its version in the Server header and on error pages until server_tokens is switched off, and Express adds X-Powered-By: Express until the app calls app.disable('x-powered-by'). Neither is a bug; each is a value someone must change in every deployment.
-
How should an error handler decide whether to show a stack trace?
Show detail only when development is asked for explicitly, with two values that only a developer's machine sets. A test such as NODE_ENV !== 'production' treats a missing value, a typo like prod and every new environment as development. Send a short message with a reference to the server's log, and keep a leak check in the tests.
-
How does a hardening checklist stay true over time?
It runs as rules on the configuration (policy as code): each rule reads one setting, names its requirement and says what to change. Run the rules on every config change before merge, on each environment's final config before deploy, and on a schedule against the live system, which shows a value changed by hand the next morning.
-
How may development, staging and production differ?
Staging and production only in host names and references to their own secrets, and development only by a written list of exceptions. A setting tested in staging protects production only if production has it too: SameSite=None in production against Lax in staging means forms from other sites carry the cookie only in production.
-
Where do credentials belong, and what happens to a key in a front-end variable?
In a secret store, not in the config, the code or a bundle (ASVS 5.0.0 13.3.1 asks for a secrets manager at level 2). Anything a browser receives is public: Vite writes every variable named VITE_ into client code, so a VITE_PAYMENT_KEY reaches every visitor. Environment variables beat files in the repository but still leak into child processes and logs (CWE-526).
-
What is an exposure probe, and why run them after every deploy?
A request for something that must not be there, such as /.git/config, a debug page, an uploads listing, TRACE, an environment file or a database backup, expecting 404 (or 401 or 403 for docs and monitoring). It takes milliseconds and turns ASVS section 13.4 into a smoke test; a deployment hardened from memory failed eight of the lesson's nine probes.
Key takeaways
- Most misconfigurations are defaults nobody changed, such as debug modes, install-guide logins, listings, verbose errors, open internal endpoints and version banners, and ASVS 5.0.0 has a requirement for each.
- An error handler that shows detail only when development is switched on explicitly cannot leak because a variable is missing or misspelled, and a leak check in the tests proves it.
- A hardening checklist holds when it runs as rules on every config change, before every deploy and on a schedule.
- Staging and production should differ only in hosts and references to secrets, development only by written exceptions, and no secret belongs in a config file, the code or a client bundle.
- Exposure probes that ask each deployment for what must not be there turn ASVS section 13.4 into a test that runs after every deploy.
Terms in this lesson
- configuration drift
- A difference that grows between environments that should match, such as a value changed by hand in production but not in staging, so that tests in one no longer predict the other.
- hardening
- Removing or switching off everything a system does not need and setting what remains to its safest working values, by a repeatable process, so that every environment starts locked down the same way.
- policy as code
- Rules about configuration written as a program that a pipeline runs on every change and every deploy, each naming the requirement it checks, instead of a checklist someone has to remember.
- secure default
- A setting whose value is safe when nobody sets it, such as directory listing off until someone switches it on. A forgotten step then costs a feature instead of exposing something.
- smoke test
- A short set of checks run right after a deployment to show that it works and is closed, such as asking for /.git/config and expecting 404.
References
- A02:2025 Security Misconfiguration (OWASP Foundation)
- Application Security Verification Standard 5.0.0: V13 Configuration (OWASP Foundation)
- Application Security Verification Standard 5.0.0: V6 Authentication (default accounts) (OWASP Foundation)
- Express: error handling (the default error handler) (Express (OpenJS Foundation))
- Express 5.x API: application settings (env, x-powered-by) (Express (OpenJS Foundation))
- Express: production best practices, security (Express (OpenJS Foundation))
- Django: deployment checklist (Django Software Foundation)
- Flask: debugging application errors (Pallets)
- nginx: the server_tokens directive (nginx)
- Vite: env variables and modes (Vite)
- CWE-526: Cleartext Storage of Sensitive Information in an Environment Variable (The MITRE Corporation (CWE))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress