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.go.flow.secret-in-response

A server-side secret read from the environment flows into an HTTP response body, leaking it to the client.

CWE-200 OWASP API3:2023 go 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

Values such as an API key, client secret, access key, or private key are meant to stay on the server; writing one to the http.ResponseWriter (via Write, fmt.Fprint(f), io.WriteString, or a JSON encoder) publishes it to every caller, including attackers probing your endpoints.

Never return a credential to the client. Send only the data the caller legitimately needs; if a secret must appear in a debug/diagnostic path, redact or mask it first. Read secrets exclusively in server-internal code and keep them out of any response payload. See CWE-200.

VULNERABLE
vulnerable.go
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
)

// Inline: an API key read from the environment is written straight to the
// response body via ResponseWriter.Write.
func leakWrite(w http.ResponseWriter, r *http.Request) {
	// ruleid: auth.go.flow.secret-in-response
	w.Write([]byte(os.Getenv("API_KEY")))
}

// Indirection: a client secret assigned to a local, then printed to the
// response with fmt.Fprint.
func leakFprint(w http.ResponseWriter, r *http.Request) {
	secret := os.Getenv("CLIENT_SECRET")
	// ruleid: auth.go.flow.secret-in-response
	fmt.Fprint(w, secret)
}

// A bearer token interpolated into the response via fmt.Fprintf.
func leakFprintf(w http.ResponseWriter, r *http.Request) {
	token := os.Getenv("AUTH_TOKEN")
	// ruleid: auth.go.flow.secret-in-response
	fmt.Fprintf(w, "token=%s", token)
}

// A database password streamed to the response with io.WriteString.
func leakWriteString(w http.ResponseWriter, r *http.Request) {
	// ruleid: auth.go.flow.secret-in-response
	io.WriteString(w, os.Getenv("DB_PASSWORD"))
}

// An access key serialised into a JSON response.
func leakJSON(w http.ResponseWriter, r *http.Request) {
	creds := os.Getenv("ACCESS_KEY")
	// ruleid: auth.go.flow.secret-in-response
	json.NewEncoder(w).Encode(creds)
}

// os.LookupEnv source: a user password flows into the response.
func leakLookup(w http.ResponseWriter, r *http.Request) {
	pw, _ := os.LookupEnv("USER_PASSWORD")
	// ruleid: auth.go.flow.secret-in-response
	fmt.Fprint(w, pw)
}

func main() {
	http.HandleFunc("/a", leakWrite)
	http.HandleFunc("/b", leakFprint)
	http.HandleFunc("/c", leakFprintf)
	http.HandleFunc("/d", leakWriteString)
	http.HandleFunc("/e", leakJSON)
	http.HandleFunc("/f", leakLookup)
	_ = http.ListenAndServe(":8080", nil)
}
SAFE
safe.go
package main

import (
	"net/http"
	"os"
)

// redact masks a secret before it is allowed anywhere near a response,
// clearing the taint.
func redact(s string) string {
	if len(s) == 0 {
		return ""
	}
	return "****"
}

// Safe: a client-public URL carries no secret — the PUBLIC_ prefix is excluded.
func writePublicURL(w http.ResponseWriter, r *http.Request) {
	// ok: auth.go.flow.secret-in-response
	w.Write([]byte(os.Getenv("PUBLIC_URL")))
}

// Safe: a NEXT_PUBLIC_-prefixed value is client-public by design even though
// its name contains "API_KEY" — the negative lookahead excludes it.
func writeNextPublic(w http.ResponseWriter, r *http.Request) {
	// ok: auth.go.flow.secret-in-response
	w.Write([]byte(os.Getenv("NEXT_PUBLIC_API_KEY")))
}

// Safe: a non-secret operational value (the listen port) is not a credential.
func writePort(w http.ResponseWriter, r *http.Request) {
	// ok: auth.go.flow.secret-in-response
	w.Write([]byte(os.Getenv("PORT")))
}

// Safe: a hard-coded constant, never a secret.
func writeConstant(w http.ResponseWriter, r *http.Request) {
	// ok: auth.go.flow.secret-in-response
	w.Write([]byte("service is healthy"))
}

// Safe: the secret is redacted before it reaches the response — the sanitizer
// clears the taint.
func writeRedacted(w http.ResponseWriter, r *http.Request) {
	masked := redact(os.Getenv("CLIENT_SECRET"))
	// ok: auth.go.flow.secret-in-response
	w.Write([]byte(masked))
}

func main() {
	http.HandleFunc("/a", writePublicURL)
	http.HandleFunc("/b", writeNextPublic)
	http.HandleFunc("/c", writePort)
	http.HandleFunc("/d", writeConstant)
	http.HandleFunc("/e", writeRedacted)
	_ = http.ListenAndServe(":8080", nil)
}

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.go.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/ ↗