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.
- 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.
- 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().
- Register the guard on every protected route (addUrlPatterns, or a securityMatcher), not route by route in each handler.
- 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.
- Answer 401 with no user data for an absent, unknown or malformed credential.
- Add regression tests that call each protected route with no credential, a forged identity header and a made-up token, and assert 401.
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
@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
@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
@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
// /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 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.
// 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());
}- 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.
- 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).
- 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.
- 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.
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.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/java | 0 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) |
| CodeQL | java/missing-jwt-signature-check | CodeQL 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 Security | authorizeHttpRequestsCentralized 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.
| Component | Version |
|---|---|
| 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) |
| org.springframework.boot:spring-boot-starter-parent | 4.1.1 |
| org.springframework.boot:spring-boot-starter-webmvc | 4.1.1 |
| org.springframework.boot:spring-boot-starter-jdbc | 4.1.1 |
| org.xerial:sqlite-jdbc | 3.53.4.0 |
| org.springframework.boot:spring-boot-starter-webmvc-test | 4.1.1 |
| org.junit.jupiter:junit-jupiter (managed by Spring Boot) | 6.0.3 |
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).
mvn -B -q test# 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 testEach 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.
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
- OWASP Top 10:2025 A07 Authentication Failures
- OWASP API Security Top 10 2023: API2 Broken Authentication
- CWE-306: Missing Authentication for Critical Function
- OWASP Authentication Cheat Sheet
- Spring Security: Authorize HttpServletRequests
- Spring Framework: Filter (OncePerRequestFilter)
Attack patterns: