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

Fix missing authentication for a critical function 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

Verify a credential on the server before any critical route runs. The guard here looks a bearer token up in the sessions table and sets the user from the stored row, or answers 401; it never trusts a userId query parameter or an X-Authenticated-User header. Register the guard on every protected route and keep /health public.

Stack
  • Language Java 21
  • Runtime OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
  • Framework Spring Boot 4.1.1 (spring-boot-starter-webmvc, OncePerRequestFilter)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 22 KB
On this page

Steps

  1. List the routes that expose user or internal data (here /me and /admin/metrics) and the public ones (/health). Treat every route as protected unless you decided otherwise.
  2. Write one guard that reads the credential, verifies it against server-side state (a session store, or a signed token you validate), and sets the user or answers 401. Here it is a OncePerRequestFilter; in a larger app use Spring Security's filter chain with authorizeHttpRequests().anyRequest().authenticated().
  3. Register the guard on every protected route (addUrlPatterns, or a securityMatcher), not route by route in each handler.
  4. Take the user id, tenant and role from the verified credential only. Delete any code that reads identity from a request parameter, the body or a header such as X-Authenticated-User.
  5. Answer 401 with no user data for an absent, unknown or malformed credential.
  6. Add regression tests that call each protected route with no credential, a forged identity header and a made-up token, and assert 401.

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…/account/impl/VulnerableAuthenticator.javaJava
@Override
public AccountUser authenticate(HttpServletRequest request) {
    String claimed = request.getParameter("userId");
    if (claimed != null) {
        List<AccountUser> found = jdbc.query(
                "SELECT id, email, role FROM users WHERE id = ?",
                (rs, n) -> new AccountUser(rs.getString("id"), rs.getString("email"), rs.getString("role")),
                claimed);
        if (!found.isEmpty()) {
            return found.get(0);
        }
    }
    // Nothing is verified: the caller picks who they are, and an unknown or missing id still
    // gets through as an anonymous guest, so no route is ever refused.
    return new AccountUser(claimed == null ? "guest" : claimed, "", "member");
}

With IMPL=vulnerable, 7 of 9 tests fail (4 exploit tests).

The fix

Fixed…/account/impl/FixedAuthenticator.javaJava
@Override
public AccountUser authenticate(HttpServletRequest request) {
    String header = request.getHeader("Authorization");
    if (header == null || !header.startsWith("Bearer ")) {
        return null;
    }
    String token = header.substring("Bearer ".length());
    List<AccountUser> found = jdbc.query(
            "SELECT u.id, u.email, u.role FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ?",
            (rs, n) -> new AccountUser(rs.getString("id"), rs.getString("email"), rs.getString("role")),
            token);
    return found.isEmpty() ? null : found.get(0);
}

With IMPL=fixed, 9 of 9 tests pass.

A fix that looks safe but is not

Wrong fix…/account/impl/WrongAuthenticator.javaJava
@Override
public AccountUser authenticate(HttpServletRequest request) {
    // "The gateway strips and re-sets this header after it authenticates." Except the gateway
    // can be bypassed, and then the header is whatever the client sent.
    String claimed = request.getHeader("X-Authenticated-User");
    if (claimed == null) {
        return null;
    }
    List<AccountUser> found = jdbc.query(
            "SELECT id, email, role FROM users WHERE id = ?",
            (rs, n) -> new AccountUser(rs.getString("id"), rs.getString("email"), rs.getString("role")),
            claimed);
    return found.isEmpty() ? null : found.get(0);
}

With IMPL=wrong, 4 of 9 tests fail (2 exploit tests).

Routes: one guard on every protected route

…/example/account/AccountController.javaJava
// /health is public; /me and /admin/metrics are registered with AuthFilter, so the signed-in
// user is already set when these run. Identity comes from the filter, never from the request.
@GetMapping("/health")
Map<String, String> health() {
    return Map.of("status", "ok");
}

@GetMapping("/me")
ResponseEntity<?> me(HttpServletRequest request) {
    AccountUser user = (AccountUser) request.getAttribute(AuthFilter.USER_ATTRIBUTE);
    List<Map<String, Object>> rows = jdbc.query(
            "SELECT id, email, role FROM users WHERE id = ?",
            (rs, n) -> Map.of("id", rs.getString("id"), "email", rs.getString("email"), "role", rs.getString("role")),
            user.id());
    return rows.isEmpty()
            ? ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "not found"))
            : ResponseEntity.ok(rows.get(0));
}

@GetMapping("/admin/metrics")
Map<String, Object> metrics() {
    Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM users", Integer.class);
    return Map.of("userCount", count);
}

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/account/AccountApiTest.javaJava
// Reachable without a credential, or defeated by a client-set header: both are missing
// authentication. Each request must be refused and leak no profile.
private void assertRefused(MvcResult result) throws IOException {
    assertThat(result.getResponse().getStatus()).as("body %s", body(result)).isEqualTo(401);
    assertThat(body(result)).doesNotContain("@example.com");
}

@Test
void exploitMetricsAreNotReachableWithoutACredential() throws Exception {
    assertRefused(mvc.perform(get("/admin/metrics")).andReturn());
}

@Test
void exploitProfileCannotBeReadByNamingAUserInTheQueryString() throws Exception {
    assertRefused(mvc.perform(get("/me?userId=bob")).andReturn());
}

@Test
void exploitForgedHeaderDoesNotReachTheInternalMetrics() throws Exception {
    assertRefused(mvc.perform(get("/admin/metrics").header("X-Authenticated-User", "sam")).andReturn());
}

@Test
void exploitForgedHeaderDoesNotAuthenticateAProfileRead() throws Exception {
    assertRefused(mvc.perform(get("/me").header("X-Authenticated-User", "bob")).andReturn());
}

Pitfalls

  • Trusting a header an upstream proxy is meant to set (X-Authenticated-User, X-Forwarded-User). If the app is reachable without the proxy, the client sets the header. That is this card's wrong implementation.
  • Protecting one route and forgetting a sibling: a filter mapped to a single path, or a missing securityMatcher, leaves a new endpoint open. Default to authenticated and list the exceptions.
  • Reading identity from a request parameter, the body or a header, so the caller answers the authentication question.
  • Accepting any non-empty token as proof: verify it against stored session state or check its signature.
  • A health or metrics endpoint that returns internal counts, versions or configuration is a critical function; keep it behind the guard or strip it to a liveness check.
  • Authentication is not authorization: once the guard names the user, still check they may read the specific object.

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 missing-authentication/java-spring does this for you).
  2. Against your own service, call every route that returns user or internal data with no Authorization header, then with a forged X-Authenticated-User, then with a random bearer token. Each must get 401 and no data.
  3. Do not expect a scanner to find this: no Semgrep registry rule or CodeQL query for Spring knows which routes must be authenticated. The regression tests are the control.

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 missing authentication for a critical function in Java and Spring Boot
ToolRuleWhat it found here
Semgrepp/java0 findings. No rule knows which routes must be authenticated, so the vulnerable, fixed and wrong authenticators look the same.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0 (src/main/java)
CodeQLjava/missing-jwt-signature-checkCodeQL has no query for a route reachable without authentication, or for a guard that trusts a client-set header; this JWT query is the nearest access-control check and does not apply (no JWT is parsed here). The regression tests are the control.Checked: github/codeql main; not executed
Spring SecurityauthorizeHttpRequestsCentralized authorization rules (an alternative to the per-route filter shown here)Not run on this card.Checked: Spring Security reference; documentation only

No Semgrep registry rule or CodeQL query for Java flags a route reachable without authentication. The regression tests are the control.

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-boot-starter-webmvc, OncePerRequestFilter)
org.springframework.boot:spring-boot-starter-parent4.1.1
org.springframework.boot:spring-boot-starter-webmvc4.1.1
org.springframework.boot:spring-boot-starter-jdbc4.1.1
org.xerial:sqlite-jdbc3.53.4.0
org.springframework.boot:spring-boot-starter-webmvc-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 missing-authentication/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 missing-authentication/java-spring runs all three modes and checks the expected results.

Download the project ZIP, 22 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • java OpenJDK 21.0.12.1
  • maven 3.9.16
  • springBoot 4.1.1
  • springFramework 7.0.9
  • junitJupiter 6.0.3
Matrix
fixed: 9 of 9 tests pass; vulnerable: 7 of 9 tests fail (4 exploit tests); wrong: 4 of 9 tests fail (2 exploit tests)

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 Top 10:2025 A07 Authentication Failures
  2. OWASP API Security Top 10 2023: API2 Broken Authentication
  3. CWE-306: Missing Authentication for Critical Function
  4. OWASP Authentication Cheat Sheet
  5. Spring Security: Authorize HttpServletRequests
  6. Spring Framework: Filter (OncePerRequestFilter)

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.