User Controlled Primary Key

ID

java.user_controlled_primary_key

Severity

high

Remediation Complexity

medium

Remediation Risk

medium

Remediation Effort

medium

Resource

Misconfiguration

Language

Java

Tags

ASVS50:v8.2.1, CWE:566, NIST.SP.800-53, PCI-DSS:6.5.6

Description

This vulnerability arises when an application uses user-supplied input to control primary key values for database queries or operations, which may lead to unauthorized data access or modification.

Rationale

When user input is directly used to build database queries, including primary keys, it can result in unauthorized access or manipulation of protected data.

This problem is particularly dangerous when primary keys are integers or predictable values, as it enables users to perform unintended actions like accessing records that belong to other users, potentially exposing or corrupting sensitive information.

For example, consider this Java code snippet:

String userId = request.getParameter("userId");

PreparedStatement stmt = connection.prepareStatement("select * from invoices where uid=?");
preparedStatement.setInt(1, id); // FLAW

preparedStatement.executeQuery();

In this example, the userId parameter is user controlled, which may allow a user to access data of any user simply by changing the userId value.

In Java the rule also reports the missing ownership check on data access: a request-derived identifier reaching a lookup that is not by primary key — a derived finder, a query by a public reference — where the record that comes back is returned or acted upon with no comparison against the caller.

What is mined and what needs a fact

Mined from the code

The primary-key sinks (the ORM and JDBC APIs, the Spring Data derived finders), that the identifier came from the request (a UUID too, which selects a record just as a numeric id does), and that the route is not served anonymously — a permitAll route has no principal, so there is no per-user decision to bypass. All of this is decided against the shipped catalogue and the project’s own Spring Security configuration, with no profile involved. This is what the rule reported before a profiler existed, and it is unchanged.

Needs a profile fact

The ownership extension needs two, and fires only with both: which field of the returned entity denotes its owner (ownershipFields) and which type this application carries its caller in (identityTypeFacts). The first says what to look at, the second says what to compare it against. With either missing the extension contributes no sink and the rule behaves exactly as above, saying so in the scan log.

The extension is withdrawn where the code already settles the question — an equals, == or != between a read of that owner field on the fetched record and a value of the identity type, a query filtered by the owner field and given the caller (findByUser_id(caller.getId())), or a call to a mechanism the profile named as an access-control enforcement point. When a handler passes the identifier to a service of the application, the service’s method is read as well, so a check made there withdraws the finding in the handler. Findings that came from a fact carry that fact and the file:line where it can be checked; findings from the catalogue are unaffected by any of it.

A login is not a reference to someone else’s record, and is not reported: a query that also filters by the password bound next to the identifier (where userid = ? and password = ?, findByPasswordAndUsername), and the lookup in the hook Spring Security loads the principal it is authenticating with (UserDetailsService.loadUserByUsername).

Nor is a JDBC read of a table the profile records as readable by anybody (publicReadEntities: a catalogue, reference data): a SELECT whose every table is one of them, such as select moduleStatus from modules where moduleId = ?. A join with any other table, and a write to the table, are still reported.

Nor is a Spring Data finder read of an entity nobody owns, as its own declaration shows: an @Entity with no reference to a user, customer, account, merchant or tenant, no column holding one’s key, and reaching no entity that has one (countryRepository.findByIsoCode(code)). Collections, the other side of a relation, do not count, so a language the merchants speak is still nobody’s. A user, a customer or a tenant looked up by the key the request sent, and any write to a reference table, are still reported.

Nor is a lookup also qualified by a record the code holds (findByCode(code, store.getId())). A handler parameter counts as such a record when the application’s own HandlerMethodArgumentResolver fills it and throws unless an authorization call answers yes; a resolver that binds the request’s value without that check leaves the parameter what the request chose.

Nor is a lookup only the application’s administrators reach, when the profile says who they are (admin_only): a lookup below the check it names, if (Validate.validateAdminSession(…)) or after an early return on its negation; on a route whose Spring Security rule requires only the roles it names (.requestMatchers("/admin/**").hasRole("ADMIN")); or on a handler, or a controller, annotated with those roles. An operator acting on any account by the key they send is how such a route is meant to be used. A route open to any other role is still reported.

A lookup made in a service or a DAO is reported when the handler hands it the identifier, a number or a UUID included. A key read off the record a lookup returned (findByUser_id(vehicle.getOwner().getId())) was chosen by the database, not by the request, and is not reported; the lookup that took the request’s identifier is.

Remediation

To mitigate the risks of CWE-566, it’s essential to avoid constructing database queries using user-supplied input directly or allow user input to control database primary key values. Best practices include:

If the SQL query is constructed without relying on user-controlled input, such as through the use of parameterized SQL, attackers cannot access unauthorized records.

When the identifier legitimately comes from the request, authorize it against the caller: check that the record belongs to the authenticated principal, and prefer doing so before the lookup, so an unauthorized identifier never reaches the database.

Whether a given method of your own performs that authorization is project knowledge, not something recoverable from the code, so the rule cannot infer it. If you delegate the check to a helper, declare that helper as a neutralizer in a custom library metadata file (conf.custom/libraries/<technology>/*.yml), naming the argument that carries the identifier and the primary_controlled_key kind:

# schema: library_schema.json
name: acme-authorization
technology: java
modules:
  - name: acme
    classes:
      - name: com.acme.security.AccessGuard
        functions:
          - name: validateUser
            neutralizations:
              - argPos: [0]
                kind: primary_controlled_key

Identifiers validated through the declared helper are then no longer reported. An inline check that is not declared this way stays reported, because nothing in the code distinguishes it from an unrelated comparison.

Some mechanisms do not check a value: they scope where the lookup runs. A wrapper runs a callback for one caller or tenant, an annotation or a guard protects a whole handler, or a call installs the scope for the statements after it. Declare such a mechanism with scopes, naming the forms it takes: wrapper, annotation or install. A lookup that runs inside the region is then not reported:

# schema: library_schema.json
name: acme-tenancy
technology: java
modules:
  - name: acme
    classes:
      - name: com.acme.tenancy.TenantContext
        functions:
          - name: runAs
            neutralizations:
              - kind: primary_controlled_key
                scopes: [ wrapper, install ]

When the application profile names an enforcement point that keeps one caller’s records away from another’s, the same regions are read from it. The forms depend on the kind of enforcement point:

  • An annotation or an interceptor (for example @UseGuards(OwnerGuard) in NestJS) scopes the handler it applies to, or every handler of the type it decorates.

  • A handler call scopes the callback it wraps, and the statements after it in the same function.

A lookup withdrawn this way is kept as an info record with suppressed_by: scoped_context, naming the profile fact and where it was asserted.

Language Wrapper (callback or block) Annotation Install

Java

lambda, try-with-resources

method, class

yes

Python

with, lambda

decorator on function or class

yes

C#

using, lambda, anonymous method

attribute on method or type

yes, including using var

JavaScript / TypeScript

arrow function, function expression

decorator, or a class named in its arguments

yes

Limits of this version:

  • A mechanism installed in another file (a servlet filter or a middleware setting the tenant for every request) does not scope a region the analysis can see.

  • A method reference, or a named function, handed to a wrapper is not a region: its body does not say where it runs.

  • An install inside a condition, a loop or a try covers its own block only. A function defined after an install and run later is not covered.

  • A function handed to an executor from inside a region, and run after the region ends, is still considered covered.

  • Kotlin, Go, PHP and Swift have no scoped regions yet. Ruby has no SAST analysis.

Configuration

The detector has the following configurable parameters:

  • sources, that indicates the source kinds to check.

  • neutralizations, that indicates the neutralization kinds to check.

Unless you need to change the default behavior, you typically do not need to configure this detector.

References

  • CWE-566 : Authorization Bypass Through User-Controlled SQL Primary Key.

© 2026 Xygeni. All rights reserved