Software Securityhome
Fix a findingSearch
Fix card · A01:2025 Broken Access Control

Fix missing object-level authorization (IDOR) 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

Get the user from Spring Security with @AuthenticationPrincipal, never from request parameters, and scope every repository query with tenant_id = :tenant AND (owner_id = :user OR :isBillingAdmin). Detail, list and export then return only rows the caller may read; map an empty result to 404. In @PostAuthorize or @PostFilter expressions, compare the tenant before any hasRole() shortcut.

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 Security 7.1.1, Spring MVC)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 25 KB
On this page

Steps

  1. List every controller method and repository query that reads invoices, including list, search, export, download and @Scheduled jobs.
  2. Let Spring Security authenticate every request (anyRequest().authenticated()) and receive the user with @AuthenticationPrincipal. Remove @RequestParam, @PathVariable or @RequestBody fields that carry a user ID, tenant ID or role.
  3. Pass the principal into the repository and add tenant_id = :tenant AND (owner_id = :user OR :isBillingAdmin) to each query, binding the values with a MapSqlParameterSource. With Spring Data, the SecurityEvaluationContextExtension from spring-security-data lets an @Query refer to the principal, as in ?#{ principal?.id }.
  4. Map an empty result to 404, so a missing invoice and someone else's look the same.
  5. For bulk endpoints, compare the rows the scoped query returned with the IDs that were asked for, and refuse the whole request if any are missing.
  6. Add MockMvc regression tests that sign in with SecurityMockMvcRequestPostProcessors.authentication(...) as a colleague, as a member of another tenant and as a billing admin of another tenant.

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…/invoices/impl/VulnerableInvoiceRepository.javaJava
@Override
public Invoice find(InvoiceUser user, long id) {
    String sql = """
            SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
            WHERE id = :id""";
    return jdbc.query(sql, Map.of("id", id), INVOICE).stream().findFirst().orElse(null);
}

@Override
public List<Invoice> list(InvoiceUser user) {
    String sql = """
            SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
            WHERE tenant_id = :tenant
            ORDER BY id""";
    return jdbc.query(sql, Map.of("tenant", user.tenantId()), INVOICE);
}

@Override
public List<Invoice> export(InvoiceUser user, List<Long> ids) {
    String sql = """
            SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
            WHERE id IN (:ids)
            ORDER BY id""";
    return jdbc.query(sql, Map.of("ids", ids), INVOICE);
}

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

The fix

Fixed…/invoices/impl/FixedInvoiceRepository.javaJava
// Members read their own invoices; billing admins read every invoice in their own
// tenant. The tenant test sits outside the parentheses, so it binds admins too:
//   tenant_id = :tenant AND (owner_id = :user OR :isBillingAdmin)
private static MapSqlParameterSource scope(InvoiceUser user) {
    return new MapSqlParameterSource()
            .addValue("tenant", user.tenantId())
            .addValue("user", user.id())
            .addValue("isBillingAdmin", user.isBillingAdmin());
}

@Override
public Invoice find(InvoiceUser user, long id) {
    String sql = """
            SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
            WHERE id = :id AND tenant_id = :tenant AND (owner_id = :user OR :isBillingAdmin)""";
    return jdbc.query(sql, scope(user).addValue("id", id), INVOICE).stream().findFirst().orElse(null);
}

@Override
public List<Invoice> list(InvoiceUser user) {
    String sql = """
            SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
            WHERE tenant_id = :tenant AND (owner_id = :user OR :isBillingAdmin)
            ORDER BY id""";
    return jdbc.query(sql, scope(user), INVOICE);
}

@Override
public List<Invoice> export(InvoiceUser user, List<Long> ids) {
    String sql = """
            SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
            WHERE id IN (:ids)
              AND tenant_id = :tenant AND (owner_id = :user OR :isBillingAdmin)
            ORDER BY id""";
    return jdbc.query(sql, scope(user).addValue("ids", ids), INVOICE);
}

With IMPL=fixed, 15 of 15 tests pass.

A fix that looks safe but is not

Wrong fix…/invoices/impl/WrongInvoiceRepository.javaJava
@Override
@PostAuthorize("returnObject == null or returnObject.ownerId() == #user.id() or hasRole('BILLING_ADMIN')")
public Invoice find(InvoiceUser user, long id) {
    String sql = """
            SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
            WHERE id = :id""";
    return jdbc.query(sql, Map.of("id", id), INVOICE).stream().findFirst().orElse(null);
}

@Override
@PostFilter("filterObject.ownerId() == #user.id() or hasRole('BILLING_ADMIN')")
public List<Invoice> list(InvoiceUser user) {
    String sql = """
            SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
            ORDER BY id""";
    return jdbc.query(sql, INVOICE);
}

@Override
@PostFilter("filterObject.ownerId() == #user.id() or hasRole('BILLING_ADMIN')")
public List<Invoice> export(InvoiceUser user, List<Long> ids) {
    String sql = """
            SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
            WHERE id IN (:ids)
            ORDER BY id""";
    return jdbc.query(sql, Map.of("ids", ids), INVOICE);
}

With IMPL=wrong, 3 of 15 tests fail (3 exploit tests).

Routes: the user comes from Spring Security, not the request

…/example/invoices/InvoiceController.javaJava
// Spring Security supplies the signed-in user from the security context. Nothing
// here reads a user, tenant or role from the request.
@GetMapping
List<Invoice> list(@AuthenticationPrincipal InvoiceUser user) {
    return invoices.list(user);
}

@GetMapping("/{id}")
ResponseEntity<Invoice> find(@PathVariable long id, @AuthenticationPrincipal InvoiceUser user) {
    Invoice invoice = invoices.find(user, id);
    // Missing and not yours get the same answer, so IDs cannot be probed.
    return invoice == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(invoice);
}

@GetMapping("/export")
ResponseEntity<String> export(@RequestParam List<Long> ids, @AuthenticationPrincipal InvoiceUser user) {
    List<Long> unique = ids.stream().distinct().toList();
    if (unique.isEmpty() || unique.size() > MAX_EXPORT_IDS) {
        return ResponseEntity.badRequest().body("ids must be 1 to " + MAX_EXPORT_IDS + " invoice IDs");
    }
    List<Invoice> rows = invoices.export(user, unique);
    // All or nothing: one missing or unreadable ID refuses the whole export, so a
    // partial file is never mistaken for the full one.
    if (rows.size() != unique.size()) {
        return ResponseEntity.notFound().build();
    }
    return ResponseEntity.ok().contentType(TEXT_CSV).body(toCsv(rows));
}

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/invoices/InvoiceApiTest.javaJava
// Exploit: other users, other tenants and other tenants' admins must get nothing.

// Denied looks exactly like missing: 404 and none of the invoice's data.
private void assertHidden(String userId, String path, String number) throws Exception {
    MvcResult result = request(userId, path);
    assertThat(result.getResponse().getStatus()).as("status, body %s", body(result)).isEqualTo(404);
    assertThat(body(result)).doesNotContain(number);
}

@Test
void exploitMemberCannotReadAColleaguesInvoiceInTheSameTenant() throws Exception {
    assertHidden("alice", "/invoices/502", "INV-502");
}

@Test
void exploitMemberOfAnotherTenantCannotReadTheInvoice() throws Exception {
    assertHidden("erin", "/invoices/501", "INV-501");
}

@Test
void exploitBillingAdminCannotReadAnInvoiceInAnotherTenant() throws Exception {
    assertHidden("cara", "/invoices/601", "INV-601");
}

@Test
void exploitMembersListLeavesOutColleaguesInvoices() throws Exception {
    assertThat(listedIds("alice")).containsExactly(501, 503);
}

@Test
void exploitBillingAdminsListStaysInsideTheirTenant() throws Exception {
    assertThat(listedIds("cara")).containsExactly(501, 502, 503);
}

@Test
void exploitExportThatIncludesAColleaguesInvoiceIsRefusedWhole() throws Exception {
    assertHidden("alice", "/invoices/export?ids=501,502", "INV-502");
}

@Test
void exploitBillingAdminsExportIntoAnotherTenantIsRefusedWhole() throws Exception {
    assertHidden("cara", "/invoices/export?ids=502,601", "INV-601");
}

Pitfalls

  • @PostAuthorize("returnObject.ownerId() == #user.id() or hasRole('BILLING_ADMIN')") is this card's wrong implementation: hasRole() never looks at the tenant. Write returnObject.tenantId() == #user.tenantId() and (returnObject.ownerId() == #user.id() or hasRole('BILLING_ADMIN')); SpEL's and binds tighter than or.
  • @PostFilter filters in memory after the query has loaded every tenant's rows. Spring Security's documentation suggests filtering in the data layer instead, and only a scoped query keeps paging and counts correct.
  • By default, method security works through a Spring AOP proxy, so a call from one method of a bean to another method of the same bean is not checked.
  • A denied @PostAuthorize throws AccessDeniedException, which Spring Security turns into 403. Map it to 404 if denied and missing invoices must look the same, as this card's controller does.
  • Spring Data JPA: findById(id) ignores ownership. Declare a method such as findByIdAndTenantId, or an @Query that carries the full scope, and keep the tenant in every derived query.
  • A tenant ID from a header or a path variable is only a selector. If you accept one, check that the signed-in user belongs to that tenant before using it.
  • Random IDs such as UUIDs make guessing harder, but leaked links, logs and browser history still expose them; the check is still required.

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-authorization/java-spring does this for you).
  2. Against your own API, sign in as two members of one tenant and as a billing admin of another tenant, then replay each other's invoice IDs on the detail, list and export routes. Every request that crosses a boundary must get a 404 or only in-scope rows.
  3. Do not expect a scanner to find this: no Semgrep registry rule or CodeQL query models object-level authorization for Spring, Semgrep's OWASP Top 10 and Java packs reported nothing on the vulnerable code, and Find Security Bugs' SPRING_ENDPOINT only lists controllers for review.

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 object-level authorization (IDOR) in Java and Spring Boot
ToolRuleWhat it found here
Semgrepp/owasp-top-tenNo findings on src/main/java: the vulnerable, fixed and wrong code look the same to every rule in these packs.Checked: Semgrep 1.180.0 (CE)
Find Security Bugs (SpotBugs plugin)SPRING_ENDPOINTFound Spring endpointAn inventory aid, not a detector: it reports every Spring controller as remotely reachable so that someone reviews it. It cannot tell whether a method compares the invoice's tenant and owner.Checked: Bug pattern listed on the Find Security Bugs site; not executed

No rule targets object-level authorization in Spring. In semgrep-rules develop at 9bbf021 the only Java rule tagged CWE-639, CWE-862 or CWE-863 checks <http-method> in web.xml security constraints. In github/codeql main at b5b8165, the only Java query with one of those tags is the experimental PermissiveDotRegex.ql (permissive regular expressions in security path matchers).

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 Security 7.1.1, Spring MVC)
org.springframework.boot:spring-boot-starter-parent4.1.1
org.springframework.boot:spring-boot-starter-webmvc4.1.1
org.springframework.boot:spring-boot-starter-security4.1.1 (Spring Security 7.1.1)
org.springframework.boot:spring-boot-starter-jdbc4.1.1
org.xerial:sqlite-jdbc3.53.4.0 (SQLite 3.53.4)
org.springframework.boot:spring-boot-starter-webmvc-test4.1.1
org.springframework.boot:spring-boot-starter-security-test4.1.1 (spring-security-test 7.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-authorization/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-authorization/java-spring runs all three modes and checks the expected results.

Download the project ZIP, 25 KB

Verified

Machine
macOS 27.0 (arm64)
Toolchain
  • java OpenJDK 21.0.12.1
  • maven 3.9.16
  • sqlite 3.53.4
Matrix
fixed: 15 of 15 tests pass; vulnerable: 6 of 15 tests fail (6 exploit tests); wrong: 3 of 15 tests fail (3 exploit tests)
Test results
IMPL=fixed 15/15 pass; IMPL=vulnerable 6/15 fail (6 exploit); IMPL=wrong 3/15 fail (3 exploit); run offline in a no-network sandbox, after installs with warm and with empty package caches

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 API Security Top 10 2023: API1 Broken Object Level Authorization
  2. OWASP Insecure Direct Object Reference Prevention Cheat Sheet
  3. OWASP Authorization Cheat Sheet
  4. OWASP Multi-Tenant Security Cheat Sheet
  5. Spring Security: Method Security (@PostAuthorize, @PostFilter)
  6. Spring Security: Spring Data integration (queries scoped to the current user)
  7. Spring Security: @AuthenticationPrincipal
  8. Spring Security: Testing with MockMvc, running as a user
  9. Spring Framework: Using NamedParameterJdbcTemplate

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.