!The Challenge
Business context
A bank exposes an account API to its customers: details, transactions, statements, transfers, limits. Every endpoint takes an account id. Login works, passwords are checked, every request is authenticated – and yet nothing checks whether the account in the URL belongs to the caller. This is Broken Object Level Authorization, number one on the OWASP API Security Top 10.
The second risk sits below the code: the libraries it runs on. A project generated today, on the current Spring Boot release, already ships with known critical vulnerabilities – and a quick scan of pom.xml says “No issues found”.
Reproduced with Java 25, Spring Boot 4.1.1, Spring Security, JUnit 5 + spring-security-test (MockMvc), CycloneDX Maven plugin and osv-scanner 2.6.0. Two demo customers: alice owns accounts 1–2, bob owns 3–4. Scans run on 8 Oct 2026 against the OSV database. All numbers are real output.
1The Code — Authenticated, Not Authorized
Spring Security does its job; the gap is one level deeper
http.authorizeHttpRequests(a -> a.anyRequest().authenticated()) // who are you? - checked
.httpBasic(Customizer.withDefaults());
@GetMapping("/{id}/statement")
public Map<String, Object> statement(@PathVariable long id, Principal me) {
Account a = store.find(id).orElseThrow(NOT_FOUND); // is it yours? - never asked
return Map.of("iban", a.iban(), "closingBalance", a.balance());
}
The filter chain answers who is calling. Only the application knows which accounts that person may touch – so no framework setting and no generic scanner closes this gap. It has to be in the code, and it has to be tested.
2The Test — An Authorization Matrix
5 endpoints × 3 callers = 15 cases
// alice owns account 1
static Stream<Arguments> endpoints() {
return Stream.of(
Arguments.of("GET /accounts/{id}", get("/accounts/1")),
Arguments.of("GET /accounts/{id}/transactions", get("/accounts/1/transactions")),
Arguments.of("GET /accounts/{id}/statement", get("/accounts/1/statement")),
Arguments.of("POST /accounts/{id}/transfers", post("/accounts/1/transfers")...),
Arguments.of("PUT /accounts/{id}/limits", put("/accounts/1/limits")...));
}
@ParameterizedTest @MethodSource("endpoints")
void ownerIsAllowed(...) { /* alice -> 2xx */ }
@ParameterizedTest @MethodSource("endpoints")
void otherCustomerIsDenied(...) { mvc.perform(request.with(httpBasic("bob", "bob")))
.andExpect(status().isNotFound()); }
@ParameterizedTest @MethodSource("endpoints")
void anonymousIsRejected(...) { /* no credentials -> 401 */ }
Most test suites cover the happy path: the owner gets their data. The matrix adds the question that matters for security – what does everyone else get? – for every endpoint, on every build.
3What the Matrix Found
./mvnw test — without the ownership check
[ERROR] Tests run: 15, Failures: 5, Errors: 0 -- in AuthorizationMatrixTest
[ERROR] otherCustomerIsDenied(...)[1] FAILURE! GET /accounts/{id}
[ERROR] otherCustomerIsDenied(...)[2] FAILURE! GET /accounts/{id}/transactions
[ERROR] otherCustomerIsDenied(...)[3] FAILURE! GET /accounts/{id}/statement
[ERROR] otherCustomerIsDenied(...)[4] FAILURE! POST /accounts/{id}/transfers
[ERROR] otherCustomerIsDenied(...)[5] FAILURE! PUT /accounts/{id}/limits
[ERROR] Tests run: 16, Failures: 5, Errors: 0, Skipped: 0
Where the problem is:
- 5 of 5 endpoints served another customer’s account to any logged-in user.
- Read and write. Not only balance and IBAN on the statement – the same gap accepted a transfer from someone else’s account and a change to their limits.
- All other tests were green. Owners got their data (5/5), anonymous calls got 401 (5/5). Without the cross-customer column the build would have passed.
Root cause: authentication treated as authorization – the resource is loaded by id, and ownership is never checked.
4The Fix
One ownership check, every endpoint goes through it
public Account accountFor(long id, String user) {
Account account = store.find(id)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND));
if (!account.owner().equals(user)) {
throw new ResponseStatusException(NOT_FOUND); // not 403
}
return account;
}
// every controller method: Account a = service.accountFor(id, me.getName());
The principle behind it:
- One place, not five. The check lives in the service that loads the account, so a new endpoint cannot “forget” it by accident.
- 404, not 403. A foreign account and a non-existent one look identical, so responses do not reveal which ids exist.
- The matrix is the guard rail. Remove the check in any future refactoring and five tests fail the build.
[INFO] Tests run: 16, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
5Dependencies — Upgrading Once Is Not Enough
osv-scanner on the same 4 libraries, three versions of history
log4j-core commons-text snakeyaml jackson-databind
legacy 2.14.1 1.9 1.30 2.13.0
-> 7 packages, 34 vulns (3 Critical, 15 High, 16 Medium)
earlier one-off upgrade 2.24.3 1.13.0 2.4 2.18.2
-> 5 packages, 23 vulns (0 Critical, 8 High, 15 Medium)
latest stable, Oct 2026 2.26.1 1.15.0 2.7 2.22.3
-> No issues found
4 declared libraries turned into 7 affected packages – the rest came in transitively. And the “fixed” set from an earlier upgrade still carried 23 known issues: vulnerabilities are published after you upgrade, so a clean result is only true on the day it was produced.
6A Brand-New App — Scan What Actually Ships
Spring Boot 4.1.1, generated the same day
$ osv-scanner scan source .
Filtered 4 local/unscannable package/s from the scan.
No issues found ← [A]
$ ./mvnw cyclonedx:makeAggregateBom && osv-scanner scan source -L target/bom.json
Scanned target/bom.json file and found 45 packages
Total 3 packages affected by 10 known vulnerabilities
(3 Critical, 5 High, 2 Medium) ← [B]
0 vulnerabilities can be fixed. ← [C]
tomcat-embed-core 11.0.24 9.8 / 9.1 / 9.1
jackson-core 3.1.5 7.5 / 7.5
jackson-databind 3.1.5 7.5 / 7.5 / 7.5 / 5.6 / 5.3
What the scans show:
- [A] A false all-clear. Versions managed by the Spring Boot parent are invisible in
pom.xml, so the source scan silently skipped them.
- [B] The SBOM sees the truth: 45 resolved packages, 10 known vulnerabilities, 3 of them critical – in an app with no business code yet.
- [C] “0 can be fixed” means no newer Spring Boot release exists: the only newer parent was a milestone (4.2.0-M2), not something to run in production.
7Triage & Patch
Reachability first, then override the managed versions
Triage example — CVSS is not the whole story:
The top finding (9.8) is CVE-2026-65905 in Tomcat’s DIGEST authenticator. This API uses Basic authentication over Spring Security, so the vulnerable code path is not reachable here – lower urgency, still patched, because configuration changes and “not reachable today” is not a guarantee. The Jackson findings are denial-of-service issues in JSON parsing, and this API parses JSON from the network – reachable, patch now.
<properties>
<java.version>25</java.version>
<tomcat.version>11.0.25</tomcat.version> <!-- managed: 11.0.24 -->
<jackson-bom.version>3.1.7</jackson-bom.version> <!-- managed: 3.1.5 -->
</properties>
tomcat 11.0.25, jackson 3.1.6 -> 2 packages, 4 vulns (0 Critical, 4 High)
tomcat 11.0.25, jackson 3.1.7 -> No issues found
./mvnw test -> Tests run: 16, Failures: 0 BUILD SUCCESS
What changed:
- 10 → 4 → 0. The first patch (Jackson 3.1.6) closed the advisories known when it was released; four newer ones were fixed only in 3.1.7. Same day, two rounds.
- Two lines of configuration, no code change, no framework upgrade.
- The authorization matrix ran after every bump – 16/16 green – so the patch did not reopen the access-control gap.
✓Summary & Takeaways
Outcome and lessons
| Check | Before | After |
| Endpoints open to another customer | 5 of 5 | 0 of 5 |
| Authorization matrix (owner / other / anonymous) | 10 / 15 pass | 15 / 15 pass |
| Legacy libraries — known vulns | 34 (3 critical) | 0 |
| New Spring Boot app (SBOM, 45 pkgs) — known vulns | 10 (3 critical) | 0 |
Source scan of pom.xml | “No issues” (4 skipped) | SBOM scan in CI |
Conclusionboth risks were invisible to the usual signals – a green test suite and a clean scan. A 15-case authorization matrix exposed five endpoints that let any customer read and modify someone else’s account; one ownership check closed them. Scanning the resolved SBOM instead of the build file revealed 10 known vulnerabilities in a brand-new app; triage plus two version overrides brought it to zero. Neither fix is a one-off – both are now build gates.
Takeaways:
- Authenticated is not authorized. Every endpoint that takes an id needs an ownership check – and a test that calls it as somebody else.
- Test the matrix, not the happy path. Owner, other customer, anonymous – for every endpoint, on every build.
- Scan what ships. Generate an SBOM from the resolved dependencies; a build-file scan can miss everything the framework manages.
- SCA is a gate, not a project. The result changed within one afternoon. Run it in CI and on a schedule, and fail the build on new critical findings.
- Triage by reachability, patch anyway. Reachability sets the order and urgency; it is not a reason to keep a known-vulnerable version.
- Regulation points the same way. The EU Cyber Resilience Act requires manufacturers to keep an SBOM, and DORA expects documented vulnerability and patch management – this pipeline produces the evidence.
Trade-offs taken into account:
- Overriding managed versions moves away from the combination Spring Boot tested. Run the full suite, and drop the overrides once a Boot patch release includes them.
- 404 instead of 403 hides existence from callers but also from support – log the real reason server-side.
- The matrix only covers endpoints listed in it. New endpoints must be added; a check that every mapped route appears in the matrix closes that gap.
- Vulnerability counts are a snapshot of the OSV database on 8 Oct 2026 and will differ on any other day – which is the point of running them continuously.
- Demo scope: in-memory users and accounts, one ownership rule. Real systems add shared accounts, delegated access and roles – more rows in the same matrix.