Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 25 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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

  1. Question 1 of 2 A freshly installed nginx answers every request with "Server nginx/1.27.4", although nobody configured that header. What is this?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An unchanged default that names the exact version, which ASVS 5.0.0 requirement 13.4.6 asks you to hide

    nginx documents server_tokens on as its default, which puts the version in the Server header and on its error pages. Nothing is broken; a default was left as it shipped, which is what most misconfigurations are.

  2. Question 2 of 2 Which of these findings belong to A02:2025 Security Misconfiguration? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • An uploads folder that lists its files
    • An admin console that accepts the login printed in the install guide
    • Debug mode switched on in production

    Misconfiguration covers settings and installed features: debug modes, listings, default accounts, open internal endpoints, version banners, missing headers. Injection and missing authorisation checks are mistakes in code and have categories of their own.

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 assumes development.
  • Django: DEBUG must 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 failing route, two error handlers, three deployments JavaScript · errors/error_modes.mjs
// 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

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:

Interactive · message by message One failing request, two error handlers Step through invoice 77 failing in a deployment where NODE_ENV was never set, first with the weak handler, then with the safe one.
One failing request, two error handlers, step 7 of 7. Messages so far: Customer's browser to Shop server: GET /invoices/77; Shop server to Customer's browser: 500 with the stack trace; Shop server to Server log: E-1042 and the stack; Shop server to Customer's browser: 500 Quote E-1042.Customer's browserholds a reference onlyShop servererror: invoice 77 missingServer logread by supportGET /invoices/77500 with the stack traceE-1042 and the stack500 Quote E-1042E-1042: invoice 77 not found

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. The request. A customer opens invoice 77. The request reaches the shop's server like any other.
  2. 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.
  3. 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.
  4. 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.
  5. The safe handler. Same request, same deployment, the safe handler. It writes the stack to the server log under the reference E-1042 first.
  6. 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.
  7. 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

  1. Question 1 of 2 An error handler sends the stack trace unless NODE_ENV === 'production'. A deploy sets NODE_ENV=Production, with a capital P. What does a customer see when a route fails?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The stack trace, because the comparison is exact and Production is not production

    A handler that shows detail unless one value is set exactly fails open: a missing value, a typo or a new environment all mean detail. In the program, two of three deployments leaked for that reason.

  2. Question 2 of 2 Which error-handler design stays safe when an environment variable is missing or misspelled?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Detail only when a developer switches it on locally, so a missing or wrong value means no detail

    Make the safe state the default: detail needs explicit values that only a developer's machine has, and one central handler catches what routes miss. A leak check in the tests proves it stays that way.

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:

Ten hardening rules against a weak config and its fix JavaScript · audit/config_audit.mjs
// 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

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

  1. Question 1 of 2 In the audit, the admin login admin:admin broke two rules, default-login and secret-in-file. Which change answers both?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A new account name and the password as a reference, such as secret:admin-password

    The two rules ask different questions: is the login a known default, and is any password written in the file at all. The fix in the program answers both with ops-asha and secret:admin-password.

  2. Question 2 of 2 Where should the hardening rules run? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • On a schedule, against what is running
    • On each environment's final config, before the deploy
    • On every change to a config file, before it merges

    Rules are cheap to run, so run them wherever configuration changes: at merge, at deploy and on a schedule. OWASP adds a manual check at least once a year for what no program can check.

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 compared before a deploy Python · parity/env_parity.py
"""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

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:

Interactive · stage by stage One build, three environments, one check Step through a release from the build to production, and see where the drift check stops it.
One build, three environments, one check, step 7 of 7. Showing One build, Settings template, Development file, Staging file, Production file, Drift and secret check (no problems), Deploy to production. In focus: Deploy to production.problem foundOne buildthe artifact staging testedSettings templateevery security settingDevelopment filehosts and written exceptionsStaging filehosts and secret referencesProduction filehosts and secret referencesDrift and secret checkno problemsDeploy to production

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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

  1. Question 1 of 2 Staging sets the session cookie with SameSite=Lax, production with SameSite=None. A form on another site posts to /account/email. What differs?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: In production the post carries the session cookie; in staging it arrives without one, so staging's tests never saw the risk

    A setting tested in staging protects production only when production has the same value. Here the drift decides whether a cross-site form post is signed in, which is why the parity check stops the deploy.

  2. Question 2 of 2 A front-end build reads VITE_PAYMENT_KEY to call a payment service from the browser. Who can read the key?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Every visitor, because Vite writes VITE_ variables into the client code at build time

    Vite exposes VITE_ variables to client code and warns that they must not hold API keys. Keep the key on your server and let the front end call your server instead.

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:

Nine exposure probes against two deployments JavaScript · exposure/exposure_probes.mjs
// 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

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

  1. Question 1 of 2 Your probe for /api/docs accepts 401, 403 or 404. A deployment answers 200 with the documentation. What does it mean?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The internal API documentation is readable by anyone, a finding unless exposing it was a decision (ASVS 13.4.5)

    Each probe states what a closed deployment answers. ASVS 13.4.5 allows documentation and monitoring endpoints only on purpose, so a 200 from a stranger's request fails the deploy.

  2. Question 2 of 2 Your nine probes pass after a deploy, yet a later test finds /backup/site.tar open. Why did the probes miss it, and what fixes that?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A probe only finds what it asks for, so add the backup names your own scripts write to the list

    Probes are a list, and the list comes from your stack: its framework's debug routes, the server's status pages, the names your backup and build scripts write. Grow it whenever a new kind of file or endpoint appears.

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.

  1. Audit one environment's config, with its written exceptions To do
  2. 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 outside development.
  • **dir-listing**: directory listing is on, in any environment.
  • **trace**: the TRACE method is on outside development.
  • **cookie**: cookies are sent without Secure and the host is not localhost.
  • **default-password**: the admin password is one of DEFAULT_PASSWORDS, the empty password included.
  • **exposed**: the list of exposed paths holds /.git or /.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.

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 in DEV_EXCEPTIONS.
  • **'literal:<environment>:<setting>'** for every secret, in any environment, whose value does not start with secret:.

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.

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.

Secret & Credential Leak Scanner Paste a config file or a built bundle of your own project and find secrets that should be references to a secret store.

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.

  1. Question 1 of 11 In the OWASP Top 10:2025 data, what share of the applications tested had some misconfiguration?

    Type a number.

    Show the answer: Question 1 of 11

    Answer: 100 %

    OWASP reports that every application tested had some form of misconfiguration, which helped move the category from fifth place to second.

  2. Question 2 of 11 An Express app answers every request with X-Powered-By set to Express. What removes the header, and which requirement does that serve?

    Choose one answer.

    Show the answer: Question 2 of 11

    Answer: app.disable('x-powered-by'), for ASVS 13.4.6, no detailed version information

    The header is an Express default (x-powered-by is true unless set otherwise). Switching it off hides one clue; the Express documentation is honest that a determined attacker can still tell Express apart.

  3. Question 3 of 11 Two file servers do the same job. One lists folders unless listing is switched off; the other lists them only when listing is switched on. You will run ten environments. Which is safer, and why?

    Choose one answer.

    Show the answer: Question 3 of 11

    Answer: The second, because an environment where someone forgot the setting stays closed

    Defaults are what every new environment starts with. The server whose default is closed fails safe when a step is missed; the other leaks.

  4. Question 4 of 11 What does Django's deployment checklist say DEBUG = True shows in production? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 4 of 11

    Answer:

    • Settings and the libraries used
    • Local variables
    • Excerpts of your source code

    Django's checklist says never to enable debug in production, because it leaks excerpts of source code, local variables, settings and the libraries used.

  5. Question 5 of 11 A Flask app runs in production with its development server and debug=True. Why is that worse than a page that shows a stack trace?

    Choose one answer.

    Show the answer: Question 5 of 11

    Answer: The debugger lets anyone who reaches it run Python code from the browser, and its PIN is not to be relied on

    Flask warns never to run the development server or the debugger in production: the debugger executes arbitrary Python code from the browser, behind a PIN that is not a security boundary.

  6. Question 6 of 11 What does each rule of the lesson's audit carry? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 6 of 11

    Answer:

    • The requirement it checks, an ASVS or CWE identifier
    • A message that says exactly what is wrong
    • The setting it reads

    A rule that reads one setting, names its requirement and says what to change can run at merge, at deploy and on a schedule, and a finding can be fixed without asking anyone.

  7. Question 7 of 11 The deploy reads debug from an environment file, where every value is text. The audit rule is below. The file says DEBUG=false. Does the rule fire?

    Read the code, then choose one answer.

    const config = { debug: process.env.DEBUG };   // DEBUG=false in the file
    
    const rule = (c) => c.debug && 'debug mode is on';
    console.log(rule(config));
    Show the answer: Question 7 of 11

    Answer: Yes, it prints "debug mode is on", because "false" is a non-empty string and counts as true

    Values from environment files arrive as text, so parse them on purpose (debug === 'true') and give the audit the parsed config. The same mistake can switch debug on in the app itself, which is one more reason for a rule that reads the final config.

  8. Question 8 of 11 Which settings should differ between staging and production?

    Choose one answer.

    Show the answer: Question 8 of 11

    Answer: Host names and the references to each environment's own secrets

    OWASP asks for environments configured identically with different credentials. Host names differ by design; development may relax only what a written list allows.

  9. Question 9 of 11 Why does the parity check print "(literal)" instead of the database password it found?

    Choose one answer.

    Show the answer: Question 9 of 11

    Answer: A report that shows a secret leaks it into every log and screen that keeps the report

    Name the setting and where it was found, never its value. The fix is the same either way: move the secret to the store and put a reference in the file.

  10. Question 10 of 11 Which ASVS 5.0.0 requirement does the probe of /.git/config check?

    Choose one answer.

    Show the answer: Question 10 of 11

    Answer: 13.4.1, no version-control folders in the deployment

    ASVS 13.4.1, a level 1 requirement, asks that folders such as .git or .svn are not deployed, or at least not reachable. A probe that expects 404 for /.git/config tests it in milliseconds.

  11. Question 11 of 11 Your team wants the exposure probes in the pipeline. When should they run, and against what?

    Choose one answer.

    Show the answer: Question 11 of 11

    Answer: Right after each deploy, against your own staging and production addresses

    The config audit checks what should run; probes check what does. Run them after each deploy against your own environments, and only ever against systems you own or have written permission to test.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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).

  7. 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.