v0.14 is out: a mobile auth pack for Swift/iOS and Android, catching insecure token storage, cleartext traffic, and OAuth in embedded WebViews. Read more →
HIGH AI PREVALENCE: HIGH auth.py.flow.secret-in-response

A server-side secret read from the environment flows into an HTTP response sent back to the client, leaking it (CWE-200).

CWE-200 OWASP API3:2023 python DATAFLOW

Why AI tools produce this: AI coding tools generate this anti-pattern by default, it appears in a large share of AI-written auth code.

Dataflow rule. This is a taint-mode rule: it traces a hardcoded secret, token or credential through your code to the HTTP response body, so indirection across multiple lines is caught, not just the direct one-line form. Routing the value through a recognised validation / allow-list sanitizer clears the taint and suppresses the finding. Why dataflow →

Why this matters

Anything you return from a Flask view (via jsonify(...), make_response(...), Response(...)) is visible to every caller, so an env value whose name looks like a credential (API_KEY, CLIENT_SECRET, *_TOKEN, *_PASSWORD, a private key, an access key, ...) must never reach it.

Never send a server secret to the client. Return only the data the caller needs; keep credentials server-side. If a value genuinely must be surfaced, redact or mask it first (redact(...) / mask_secret(...)), and prefer exposing only public configuration (names prefixed PUBLIC_ / NEXT_PUBLIC_ / VITE_) to clients.

VULNERABLE
vulnerable.py
"""Flask views that leak a server secret into the HTTP response (CWE-200)."""

import os
from os import environ, getenv

import flask
from flask import Flask, jsonify, make_response, Response

app = Flask(__name__)


@app.route("/v1")
def leak_api_key_inline():
    # Direct: a credential-named env value returned in the JSON body.
    # ruleid: auth.py.flow.secret-in-response
    return jsonify(api_key=os.environ["API_KEY"])


@app.route("/v2")
def leak_client_secret_indirect():
    # Indirection: secret assigned to a local, then returned.
    secret = os.getenv("CLIENT_SECRET")
    # ruleid: auth.py.flow.secret-in-response
    return jsonify(secret=secret)


@app.route("/v3")
def leak_token_in_dict():
    # Secret nested inside the JSON payload dict.
    # ruleid: auth.py.flow.secret-in-response
    return jsonify({"token": os.environ["ACCESS_TOKEN"]})


@app.route("/v4")
def leak_password_make_response():
    # Secret returned positionally via make_response.
    # ruleid: auth.py.flow.secret-in-response
    return make_response(os.environ["DB_PASSWORD"])


@app.route("/v5")
def leak_private_key_response():
    # Secret returned via the Response constructor.
    pem = os.getenv("PRIVATE_KEY")
    # ruleid: auth.py.flow.secret-in-response
    return Response(pem)


@app.route("/v6")
def leak_qualified_environ():
    # Qualified `from os import environ` source into flask.jsonify.
    # ruleid: auth.py.flow.secret-in-response
    return flask.jsonify(credential=environ["SERVICE_CREDENTIAL"])


@app.route("/v7")
def leak_qualified_getenv():
    # Qualified `from os import getenv` source into jsonify.
    access = getenv("AWS_ACCESS_KEY")
    # ruleid: auth.py.flow.secret-in-response
    return jsonify(access_key=access)
SAFE
safe.py
"""Responses that do NOT leak a secret — must produce zero findings."""

import os
from os import getenv

from flask import Flask, jsonify, make_response, Response

app = Flask(__name__)


def redact(value):
    # Masks all but the first/last character.
    if not value:
        return value
    return value[0] + "***" + value[-1]


@app.route("/s1")
def public_env_value():
    # Public-by-convention name: not a secret, prefixes are excluded.
    return jsonify(url=os.environ["NEXT_PUBLIC_API_URL"])


@app.route("/s2")
def public_prefixed_key():
    # PUBLIC_ prefix is excluded even though it contains "KEY".
    return jsonify(api_key=os.environ["PUBLIC_API_KEY"])


@app.route("/s3")
def non_secret_env_value():
    # Operational config, not a credential.
    return jsonify(port=os.getenv("PORT"))


@app.route("/s4")
def constant_value():
    # A literal constant, nothing from the environment.
    return jsonify(status="ok")


@app.route("/s5")
def redacted_secret():
    # Secret is masked before it reaches the response — taint cleared.
    return jsonify(api_key=redact(os.environ["API_KEY"]))


@app.route("/s6")
def secret_kept_server_side():
    # Secret is used to authenticate upstream, never returned to the client.
    token = getenv("UPSTREAM_API_TOKEN")
    _ = {"Authorization": f"Bearer {token}"}
    return make_response("done")


@app.route("/s7")
def vite_public_config():
    # VITE_ prefix is excluded.
    return Response(os.getenv("VITE_PUBLIC_TOKEN"))

Suppressing this rule

If a finding is a genuine false positive, scope the suppression to the exact line and leave a reason, never disable the rule project-wide. Disable directives are line-scoped by design.

# oauthlint-disable-next-line auth.py.flow.secret-in-response -- <reason>

References

https://cwe.mitre.org/data/definitions/200.html ↗https://owasp.org/API-Security/editions/2023/en/0xa3-broken-object-property-level-authorization/ ↗