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

Fix hard-coded secrets in Java and Spring Boot

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

In short

Bind the key from the Spring Environment with @ConfigurationProperties and check it in the record's constructor, so a missing or blank value stops the app at startup. Supply it as FORECAST_APIKEY or a mounted secret from your secret store, never in a committed application*.properties file, and send it in the Authorization header, not the URL.

Stack
  • Language Java 21
  • Runtime OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
  • Framework Spring Boot 4.1.1 (Spring Framework 7.0.9, Spring MVC, RestClient)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks3 cards
Download the project ZIP, 28 KB
On this page

Steps

  1. Find the keys: search the code, every application*.properties and application*.yml file and the history for credentials, and decode base64 strings next to key-like names. Revoke and replace every key you find at the provider before anything else; deleting the commit does not unpublish it.
  2. Bind the secret with a @ConfigurationProperties record (this card's Settings) and reject a missing or blank value in its compact constructor, or add spring-boot-starter-validation and annotate the record @Validated with @NotBlank.
  3. Supply the value at deploy time: the environment variable FORECAST_APIKEY (Boot's documented rule for forecast.api-key replaces dots with underscores, drops dashes and upper-cases the rest; Boot 4.1.1 also bound FORECAST_API_KEY in this card's check), a Kubernetes or Docker secret mounted as files and imported with spring.config.import=optional:configtree:/run/secrets/, or a secret-store integration such as Spring Cloud Vault.
  4. Keep only settings that are not secret in the committed application.properties. Do not give the key a default such as @Value("${forecast.api-key:dev-key}").
  5. Override toString() of any record or class that holds the key; a record prints every component.
  6. Send the key with RestClient.Builder.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + key), never in the query string.
  7. On partner failures log the status code or exception type and return a generic 502.
  8. Add regression tests like this card's: scan src/main for the key, start the app without it and expect the context to fail, and capture the log output with OutputCaptureExtension 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

Vulnerable…/forecast/impl/VulnerableForecast.javaJava
private static final String FORECAST_API_KEY = "FAKE-forecast-key-for-tests-only";

With IMPL=vulnerable, 6 of 11 tests fail (3 exploit tests).

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

Vulnerable…/forecast/impl/VulnerableForecast.javaJava
private static final Logger log = LoggerFactory.getLogger(VulnerableForecast.class);

private final RestClient partner = RestClient.create();
private final String baseUrl;

public VulnerableForecast(@Value("${forecast.base-url}") String baseUrl) {
    this.baseUrl = baseUrl;
}

@GetMapping("/forecast")
public ResponseEntity<?> forecast(@RequestParam(defaultValue = "") String city) {
    if (city.isBlank()) {
        return ResponseEntity.badRequest().body(Map.of("error", "city is required"));
    }
    String url = baseUrl + "/v1/forecast?city=" + URLEncoder.encode(city, StandardCharsets.UTF_8)
            + "&api_key=" + FORECAST_API_KEY;
    try {
        Forecast forecast = partner.get().uri(URI.create(url)).retrieve().body(Forecast.class);
        return ResponseEntity.ok(new Forecast(city, forecast.summary(), forecast.temperatureC()));
    } catch (RestClientException e) {
        log.warn("GET {} failed: {}", url, e.getMessage());
        return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(Map.of("error", e.getMessage()));
    }
}

With IMPL=vulnerable, 6 of 11 tests fail (3 exploit tests).

The fix

Fixed…/forecast/impl/FixedForecast.javaJava
/**
 * Bound from forecast.* in the Environment: FORECAST_APIKEY, a mounted secret
 * (spring.config.import=configtree:/run/secrets/) or a secret store. Binding happens while
 * the context starts, so a missing key stops the app.
 */
@ConfigurationProperties("forecast")
public record Settings(String apiKey, URI baseUrl) {
    public Settings {
        if (apiKey == null || apiKey.isBlank()) {
            // Name the missing setting, never a value.
            throw new IllegalStateException(
                    "forecast.api-key is not set. Provide FORECAST_APIKEY from your secret store.");
        }
    }

    @Override
    public String toString() { // a record prints every component; keep the key out of logs
        return "Settings[apiKey=<redacted>, baseUrl=" + baseUrl + "]";
    }
}

With IMPL=fixed, 11 of 11 tests pass.

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

Fixed…/forecast/impl/FixedForecast.javaJava
private static final String UNAVAILABLE = "The forecast service is unavailable. Try again later.";
private static final Logger log = LoggerFactory.getLogger(FixedForecast.class);

private final RestClient partner;

public FixedForecast(Settings settings) {
    this.partner = RestClient.builder()
            .baseUrl(settings.baseUrl())
            .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + settings.apiKey())
            .build();
}

@GetMapping("/forecast")
public ResponseEntity<?> forecast(@RequestParam(defaultValue = "") String city) {
    if (city.isBlank()) {
        return ResponseEntity.badRequest().body(Map.of("error", "city is required"));
    }
    try {
        Forecast forecast = partner.get()
                .uri(uri -> uri.path("/v1/forecast").queryParam("city", "{city}").build(city))
                .retrieve()
                .body(Forecast.class);
        return ResponseEntity.ok(new Forecast(city, forecast.summary(), forecast.temperatureC()));
    } catch (RestClientResponseException e) {
        log.warn("Forecast partner returned HTTP {}", e.getStatusCode().value());
    } catch (RestClientException e) {
        log.warn("Forecast partner unreachable: {}", e.getClass().getSimpleName());
    }
    return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(Map.of("error", UNAVAILABLE));
}

With IMPL=fixed, 11 of 11 tests pass.

A fix that looks safe but is not

Wrong fix…/main/resources/application-wrong.propertiesProperties
# Moved out of the Java code "into configuration". This file is committed with the code and
# packaged into the jar, so every clone, build and image still carries the partner's key.
forecast.api-key=FAKE-forecast-key-for-tests-only

With IMPL=wrong, 2 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.

Test…/example/forecast/ExploitTest.javaJava
@Test
@DisplayName("the key is not in any file that ships with the app")
void theKeyIsNotInAnyFileThatShipsWithTheApp() throws IOException {
    List<Path> files = shippedFiles();
    assertThat(files).as("the scan must cover the implementation")
            .anyMatch(file -> file.getFileName().toString().toLowerCase(Locale.ROOT).startsWith(IMPL));
    List<String> leaks = files.stream().filter(file -> revealsKey(read(file)))
            .map(file -> ROOT.relativize(file).toString()).toList();
    assertThat(leaks).as("the key can be read from these files").isEmpty();
}

@Test
@DisplayName("the key is not sent in the URL, where proxies and access logs keep it")
void theKeyIsNotSentInTheUrl() {
    app.get("/forecast?city=Oslo");
    assertThat(PARTNER.requests).as("the app did not call the partner API").hasSize(1);
    String url = URLDecoder.decode(PARTNER.requests.getFirst().url(), StandardCharsets.UTF_8);
    assertThat(revealsKey(url)).as("the key is in the request URL: %s", redact(url)).isFalse();
}

@Test
@DisplayName("a partner error does not put the key in the response")
void aPartnerErrorDoesNotPutTheKeyInTheResponse() {
    PARTNER.outage = true;
    Response res = app.get("/forecast?city=Oslo");
    assertThat(revealsKey(res.body())).as("the %d response contains the key: %s", res.status(), redact(res.body())).isFalse();
}

@Test
@DisplayName("an unreachable partner does not put the key in the response")
void anUnreachablePartnerDoesNotPutTheKeyInTheResponse() {
    try (App down = startApp(settings(KEY, PartnerStub.unreachableUrl()))) {
        Response res = down.get("/forecast?city=Oslo");
        assertThat(res.status()).isEqualTo(502);
        assertThat(revealsKey(res.body())).as("the 502 response contains the key: %s", redact(res.body())).isFalse();
    }
}

@Test
@DisplayName("the key is never written to the logs")
void theKeyIsNeverWrittenToTheLogs(CapturedOutput output) {
    String[] verbose = {"logging.level.com.example", "DEBUG", "logging.level.org.springframework.web", "DEBUG"};
    try (App up = startApp(settings(KEY, PARTNER.url(), verbose));
            App down = startApp(settings(KEY, PartnerStub.unreachableUrl(), verbose))) {
        up.get("/forecast?city=Oslo");
        PARTNER.outage = true;
        up.get("/forecast?city=Oslo");
        down.get("/forecast?city=Oslo");
    }
    assertThat(revealsKey(output.getAll())).as("the logs contain the key:%n%s", redact(output.getAll())).isFalse();
}

Pitfalls

  • A key in application.properties or a profile file such as application-prod.properties is committed with the code and packaged into the jar. Environment variables and command-line arguments override it, so the leak goes unnoticed in production while everyone with the repository or the jar has the key.
  • A key in a constant is in the compiled class's constant pool; anyone with the jar can read it.
  • Records print every component in toString(), so logging a settings record prints the key unless toString() is overridden. Lombok's @Data and @ToString do the same.
  • RestClient leaves the query string out of its exception messages, but URLs that you log yourself keep it; the vulnerable implementation leaks the key through its own log.warn("GET {} failed", url).
  • Actuator's /env and /configprops endpoints mask every value by default; setting management.endpoint.env.show-values to always exposes the key to anyone who can reach the endpoint.
  • Spring Boot's DEBUG logging for org.springframework.web masks request parameters and headers by default (enableLoggingRequestDetails=false); do not switch that on in production.

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/java-spring does this for you).
  2. Start the app without the key and expect it to fail with "forecast.api-key is not set" in the startup failure report.
  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. detect-secrets did not flag the key in application-wrong.properties, so also search properties and YAML files for key-like names yourself.

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 Java and Spring Boot
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 VulnerableForecast.java but not application-wrong.properties, because api-key does not fit its api_?key pattern.Checked: Semgrep 1.180.0 (CE)
Semgrepgeneric.secrets.gitleaks.generic-api-key.generic-api-keyNo findings on this card, because its stopword list (fake, test) excludes the card's key. On the probe copy with a random key it flagged application-wrong.properties but not the Java constant, since it drops matches on lines with an upper-case name ending in _KEY.Checked: Semgrep 1.180.0 (CE)
CodeQLjava/hardcoded-credential-api-callNot expected to report anything: its sinks are the credentials-* models in java/ql/lib/ext (JDBC connections, key specs, keystores, cloud SDK clients and similar). No HTTP client header or URL is among them, so a key sent to a partner API reaches no sink.Checked: github/codeql main at b5b8165; not executed
CodeQLjava/sensitive-logNot expected to report the vulnerable log line: SensitiveLoggingQuery.qll takes reads of sensitively named variables as sources but excludes compile-time constants, and FORECAST_API_KEY is a static final String constant.Checked: github/codeql main at b5b8165; not executed
Find Security Bugs (SpotBugs plugin)HARD_CODE_PASSWORDHard coded password (and HARD_CODE_KEY)No HARD_CODE_PASSWORD or HARD_CODE_KEY findings. It reported CRLF_INJECTION_LOGS on the vulnerable log line, which writes the user's city into the log, plus informational SPRING_ENDPOINT notes on all three controllers.Checked: SpotBugs 4.10.4 with Find Security Bugs 1.14.0 (effort max, threshold low)
detect-secrets (Yelp)KeywordDetectorSecret KeywordFlagged VulnerableForecast.java line 32 even with the card's fake key. It did not flag application-wrong.properties, with the card's key or a random one.Checked: detect-secrets 1.5.0

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJava 21
RuntimeOpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
FrameworkSpring Boot 4.1.1 (Spring Framework 7.0.9, Spring MVC, RestClient)
org.springframework.boot:spring-boot-starter-parent4.1.1
org.springframework.boot:spring-boot-starter-webmvc4.1.1
org.apache.tomcat.embed:tomcat-embed-core (overrides Boot's 11.0.24)11.0.26
tools.jackson:jackson-bom (overrides Boot's 3.1.5)3.1.7
org.springframework.boot:spring-boot-starter-test4.1.1
org.junit.jupiter:junit-jupiter (managed by Spring Boot)6.0.3

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/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).

Shell
mvn -B -q test
Shell
# Expected: every test passes
IMPL=fixed mvn -B -o -q test

# Expected: an exploit test fails
IMPL=vulnerable mvn -B -o -q test

# Expected: a test fails
IMPL=wrong mvn -B -o -q test

Each run writes a JUnit report to target/surefire-reports/TEST-*.xml. From the top folder of the download, node run-all.mjs hardcoded-secrets/java-spring runs all three modes and checks the expected results.

Download the project ZIP, 28 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • java OpenJDK 21.0.12.1 (Homebrew)
  • maven 3.9.16
  • springBoot 4.1.1
  • springFramework 7.0.9
  • tomcat 11.0.26
  • jackson 3.1.7
  • junitJupiter 6.0.3
Matrix
fixed: 11 of 11 tests pass; vulnerable: 6 of 11 tests fail (3 exploit tests); wrong: 2 of 11 tests fail (1 exploit test)
Test results
fixed 11/11 pass; vulnerable 6/11 fail (3 of the 5 exploit tests); wrong 2/11 fail (1 exploit test), offline after install
Dependency freshness
Spring Boot 4.1.1 was published on 2026-08-20. Boot 4.1.1 manages Tomcat 11.0.24 and Jackson 3.1.5, which OSV lists with advisories (GHSA-9xv2-5v5q-p794, GHSA-gcx9-497g-6cp6 and GHSA-h3x4-894j-xpx5 for Tomcat; GHSA-cxp5-3px4-pw24 and six others for jackson-databind and jackson-core), so the pom overrides tomcat.version to 11.0.26 and jackson-bom.version to 3.1.7, as the other cards do. OSV listed no advisories for the 68 resolved artifacts after the overrides. Maven has no lockfile; the Boot parent and the two overrides fix every version.

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. Spring Boot: Externalized Configuration (binding from environment variables)
  3. Spring Boot: Externalized Configuration (configuration trees for mounted secrets)
  4. Spring Boot: Actuator endpoints (sanitize sensitive values)
  5. Spring Framework 7.0.9 source: DefaultResponseErrorHandler (query string left out of error messages)
  6. Find Security Bugs: HARD_CODE_PASSWORD
  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.