Software Securityhome
Fix a findingSearch
Fix card · A07:2025 Authentication Failures

Fix hard-coded secrets in Python and Flask

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Read the key from os.environ when the app is created, where the deployment puts it from your secret store, and raise if it is missing or blank so the app never starts without it. Send it in the Authorization header, not the query string, return a generic 502 on partner errors, and keep .env files out of git.

On this page

Steps

  1. Find the keys: search the code and its history for string constants named like key, token, secret or password, for committed .env, config.py and instance files, and for base64 or hex strings that decode to credentials. Revoke and replace every key you find at the provider before anything else; deleting the commit does not unpublish it.
  2. Read each secret once, in create_app(), from os.environ (this card's load_settings), and raise with the variable's name when it is missing or blank. Do not use os.environ.get(name, "dev-key").
  3. Fill the variable at deploy time from the secret store, for example a Kubernetes Secret or a secret-manager client at startup. Never bake it into the image with a Dockerfile ENV or ARG line.
  4. For local development keep the values in a gitignored .env file; the flask command loads it when python-dotenv is installed, and production code should not. Commit a .env.example that lists the names with empty values.
  5. Mark the field repr=False (or use a type such as pydantic's SecretStr) so that printing the settings object does not print the key.
  6. Send the key in the Authorization header of a requests.Session, never in params.
  7. On partner failures log the status code or exception type and return a generic 502; never return str(exc) from requests, whose messages include the full URL.
  8. Add regression tests like this card's: scan the files that ship for the key (decoding base64), create the app without it and expect a refusal, and check responses and caplog during partner failures.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.pyPython
FORECAST_API_KEY = "FAKE-forecast-key-for-tests-only"


def load_settings(environ: Mapping[str, str]) -> dict[str, str]:
    return {"base_url": environ.get("FORECAST_BASE_URL", "https://api.forecast.example")}

With IMPL=vulnerable, 8 of 11 tests fail (5 exploit tests).

The vulnerable code: the key in the URL, the error and the log

Vulnerableimpl/vulnerable.pyPython
def register_routes(app: Flask, settings: dict[str, str]) -> None:
    @app.get("/forecast")
    def forecast():
        city = request.args.get("city", "").strip()
        if not city:
            return {"error": "city is required"}, 400
        try:
            resp = requests.get(
                f"{settings['base_url']}/v1/forecast",
                params={"city": city, "api_key": FORECAST_API_KEY},
                timeout=5,
            )
            resp.raise_for_status()
        except requests.RequestException as exc:
            app.logger.error("forecast lookup failed: %s", exc)
            return {"error": str(exc)}, 502
        data = resp.json()
        return {"city": city, "summary": data["summary"], "temperatureC": data["temperatureC"]}

With IMPL=vulnerable, 8 of 11 tests fail (5 exploit tests).

The fix

Fixedimpl/fixed.pyPython
@dataclass(frozen=True)
class Settings:
    api_key: str = field(repr=False)  # repr() and tracebacks that print it never show the key
    base_url: str


def load_settings(environ: Mapping[str, str]) -> Settings:
    api_key = environ.get("FORECAST_API_KEY", "").strip()
    if not api_key:
        # Name the missing setting, never a value.
        raise RuntimeError("FORECAST_API_KEY is not set. Provide it from your secret store.")
    return Settings(api_key=api_key, base_url=environ.get("FORECAST_BASE_URL", "https://api.forecast.example"))

With IMPL=fixed, 11 of 11 tests pass.

The fix: send the key in a header and keep errors generic

Fixedimpl/fixed.pyPython
UNAVAILABLE = "The forecast service is unavailable. Try again later."


def register_routes(app: Flask, settings: Settings) -> None:
    partner = requests.Session()
    partner.headers["Authorization"] = f"Bearer {settings.api_key}"

    @app.get("/forecast")
    def forecast():
        city = request.args.get("city", "").strip()
        if not city:
            return {"error": "city is required"}, 400
        try:
            resp = partner.get(f"{settings.base_url}/v1/forecast", params={"city": city}, timeout=5)
            resp.raise_for_status()
        except requests.HTTPError as exc:
            app.logger.warning("forecast partner returned HTTP %s", exc.response.status_code)
            return {"error": UNAVAILABLE}, 502
        except requests.RequestException as exc:
            app.logger.warning("forecast partner unreachable: %s", type(exc).__name__)
            return {"error": UNAVAILABLE}, 502
        data = resp.json()
        return {"city": city, "summary": data["summary"], "temperatureC": data["temperatureC"]}

With IMPL=fixed, 11 of 11 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.pyPython
FORECAST_API_KEY = base64.b64decode("RkFLRS1mb3JlY2FzdC1rZXktZm9yLXRlc3RzLW9ubHk=").decode()


def load_settings(environ: Mapping[str, str]) -> Settings:
    return Settings(api_key=FORECAST_API_KEY, base_url=environ.get("FORECAST_BASE_URL", "https://api.forecast.example"))

With IMPL=wrong, 4 of 11 tests fail (1 exploit test).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtests/test_secrets.pyPython
class TestExploit:
    def test_the_key_is_not_in_any_file_that_ships_with_the_app(self) -> None:
        files = shipped_files()
        assert ROOT / "impl" / f"{IMPL}.py" in files, "the scan must cover the implementation"
        leaks = [str(f.relative_to(ROOT)) for f in files if reveals_key(f.read_text(encoding="latin-1"))]
        assert leaks == [], f"the key can be read from: {', '.join(leaks)}"

    def test_the_key_is_not_sent_in_the_url_where_proxies_and_access_logs_keep_it(
        self, client: FlaskClient, partner: Partner
    ) -> None:
        client.get("/forecast?city=Oslo")
        assert len(partner.requests) == 1, "the app did not call the partner API"
        url = unquote(partner.requests[0]["url"])
        leaked = reveals_key(url)
        assert not leaked, f"the key is in the request URL: {redact(url)}"

    def test_a_partner_error_does_not_put_the_key_in_the_response(self, client: FlaskClient, partner: Partner) -> None:
        partner.outage = True
        res = client.get("/forecast?city=Oslo")
        body = res.get_data(as_text=True)
        leaked = reveals_key(body)
        assert not leaked, f"the {res.status_code} response contains the key: {redact(body)}"

    def test_an_unreachable_partner_does_not_put_the_key_in_the_response(self, make_app: Callable[..., Flask]) -> None:
        app = make_app(FORECAST_API_KEY=KEY, FORECAST_BASE_URL=unreachable_url())
        res = app.test_client().get("/forecast?city=Oslo")
        body = res.get_data(as_text=True)
        assert res.status_code == 502
        leaked = reveals_key(body)
        assert not leaked, f"the 502 response contains the key: {redact(body)}"

    def test_the_key_is_never_written_to_the_logs(
        self, make_app: Callable[..., Flask], partner: Partner, caplog: pytest.LogCaptureFixture
    ) -> None:
        caplog.set_level(logging.DEBUG)  # every logger at every level, the HTTP client's included
        up = make_app(FORECAST_API_KEY=KEY, FORECAST_BASE_URL=partner.url).test_client()
        down = make_app(FORECAST_API_KEY=KEY, FORECAST_BASE_URL=unreachable_url()).test_client()
        up.get("/forecast?city=Oslo")
        partner.outage = True
        up.get("/forecast?city=Oslo")
        down.get("/forecast?city=Oslo")
        leaked = reveals_key(caplog.text)
        assert not leaked, f"the logs contain the key:\n{redact(caplog.text)}"

Pitfalls

  • Base64 is an encoding, not encryption: base64.b64decode("...").decode() in the source is the key, and the .pyc file in the image holds the encoded string too. It also hides the key from detect-secrets and Semgrep's generic rules, which flag the plain constant (checked on a probe copy with a random key), and that is often why it was done.
  • requests puts the full URL, query string included, into HTTPError messages ("503 Server Error: ... for url: ...") and into connection errors ("Max retries exceeded with url: ..."), and urllib3 logs every request URL at DEBUG level. A key in params leaks through all three.
  • requests drops the Authorization header when a redirect leads to another host, port or scheme (except an http-to-https upgrade on the default ports), but forwards custom headers such as X-Api-Key (checked with requests 2.34.2).
  • Flask's .env support is for development: Flask's documentation says .env should not be committed, and the files are only loaded by the flask command or app.run().
  • A dataclass, NamedTuple or dict includes every field in its repr(), so logging the settings or an exception that contains them prints the key. Error trackers that capture local variables can do the same.
  • os.environ.get("FORECAST_API_KEY", "dev-key") reintroduces the hard-coded secret and defeats the startup check.
  • Bandit's hard-coded password checks look at names: FORECAST_API_KEY = "..." passes, while FORECAST_API_TOKEN = "..." is reported.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs hardcoded-secrets/python-flask does this for you).
  2. Create the app without the key, for example env -u FORECAST_API_KEY .venv/bin/python -c "from app import create_app; create_app()", and expect RuntimeError: FORECAST_API_KEY is not set.
  3. Scan the working tree with a secret scanner, for example detect-secrets scan --all-files, and the git history with a scanner that reads it, such as gitleaks or TruffleHog; treat every hit as a key to rotate. None of the scanners checked for this card decoded the base64 string, so also review long encoded strings next to key-like names.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix hard-coded secrets in Python and Flask
ToolRuleWhat it found here
Semgrepgeneric.secrets.security.detected-generic-api-key.detected-generic-api-keyNo findings on this card: the rule wants 32 to 45 letters and digits after a name like api_key, and the card's fake key contains hyphens. On a probe copy with a random 32-character key it flagged the constant in impl/vulnerable.py but not the base64 string in impl/wrong.py.Checked: Semgrep 1.180.0 (CE)
Semgrepgeneric.secrets.gitleaks.generic-api-key.generic-api-keyNo findings on this card or on the probe copy with a random key: the Semgrep port drops matches on lines with an upper-case name ending in _KEY, such as FORECAST_API_KEY, and the base64 string sits inside a function call.Checked: Semgrep 1.180.0 (CE)
Semgreppython.lang.security.audit.logging.logger-credential-leak.python-logger-credential-disclosureNo findings: it only matches logger calls whose format string mentions an API key, secret, credential, token or password next to %s. The vulnerable code logs "forecast lookup failed: %s" with an exception whose message holds the key.Checked: Semgrep 1.180.0 (CE)
CodeQLpy/hardcoded-credentialsNot expected to report anything: its sinks are keyword arguments and comparisons with names that look like a password, user name or certificate, plus credentials-* models, of which the Python library has none for requests. A key in params={"api_key": ...} or in a header reaches no sink.Checked: github/codeql main at b5b8165; not executed
CodeQLpy/clear-text-logging-sensitive-dataUncertain. FORECAST_API_KEY matches its sensitive-name heuristic (api.?(key|tok)), but the key reaches the log inside an exception that requests raises, and whether the query follows it from the params argument into that exception's message was not established.Checked: github/codeql main at b5b8165; not executed
BanditB105hardcoded_password_string (and B106, B107)No findings, with the card's key or a random one. B105 only considers names that match (pas+wo?r?d|pass(phrase)?|pwd|token|secrete?), so FORECAST_API_KEY is ignored; the same constant named FORECAST_API_TOKEN would be reported.Checked: Bandit 1.9.4, all tests
detect-secrets (Yelp)KeywordDetectorSecret KeywordFlagged impl/vulnerable.py line 15 even with the card's fake key, because it keys on the variable name. It did not flag the base64 string in impl/wrong.py, with the card's key or a random one.Checked: detect-secrets 1.5.0

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkFlask 3.1.3
flask3.1.3
requests2.34.2
urllib32.8.0
werkzeug3.1.9
pytest9.1.1

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the hardcoded-secrets/python-flaskfolder.

Shell
python3.14 -m venv .venv
.venv/bin/python -m pip install --require-hashes -r requirements.txt
Shell
# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest

# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest

# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytest

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs hardcoded-secrets/python-flask runs all three modes and checks the expected results.

Download the project ZIP, 34 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • python 3.14.8
  • pip 26.2.1
  • flask 3.1.3
  • requests 2.34.2
  • urllib3 2.8.0
  • werkzeug 3.1.9
  • pytest 9.1.1
Matrix
fixed: 11 of 11 tests pass; vulnerable: 8 of 11 tests fail (5 exploit tests); wrong: 4 of 11 tests fail (1 exploit test)
Test results
fixed 11/11 pass; vulnerable 8/11 fail (all 5 exploit tests); wrong 4/11 fail (1 exploit test), offline after install
Dependency freshness
requirements.txt pins all 18 packages with sha256 hashes for every published file. Werkzeug 3.1.9 (2026-09-27) is taken at 13 days old because it fixes GHSA-g6x2-hccm-hh4m; charset-normalizer 3.5.1, MarkupSafe 3.0.3 and iniconfig 2.3.0 are used instead of 3.5.2, 3.0.4 and 2.3.1, which were under 14 days old and fix no security issue. OSV listed no advisories for the pinned versions. Rerun on CPython 3.14.8, the 30 September 2026 expedited security release.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Secrets Management Cheat Sheet
  2. Flask: environment variables from dotenv (.env should not be committed)
  3. Flask: configuration handling
  4. requests 2.34.2 source: Response.raise_for_status() ("for url: ...")
  5. Python: dataclasses.field(repr=False)
  6. Bandit B105: hardcoded_password_string
  7. GitHub Docs: Removing sensitive data from a repository

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.