Secure coding
Notes on the code-level controls that come up repeatedly in review of Java and Spring services. Each section states the failure it prevents; the code is illustrative rather than copy-ready.
Output encoding
Section titled “Output encoding”Data that reaches an HTML page must be encoded for the context it lands in — HTML body, HTML attribute, JavaScript string, URL parameter and CSS all have different escaping rules, and a single “sanitise” function that does not know which of them it is writing into will get one of them wrong. Encode at the point of output, not on the way into storage: the same value may be rendered into more than one context, and encoding on input corrupts the stored data.
Use the OWASP Java Encoder on the server:
import org.owasp.encoder.Encode;
String safeBody = Encode.forHtml(value);String safeAttribute = Encode.forHtmlAttribute(value);String safeScriptString = Encode.forJavaScript(value);In a Thymeleaf template, th:text encodes and th:utext does not. Reaching for th:utext to
make markup render is how stored XSS gets shipped:
<!-- Unsafe: renders attacker-supplied markup --><span th:utext="${filter}"></span>
<!-- Safe --><span th:text="${filter}"></span>Where a page genuinely has to render user-supplied HTML, sanitise it with a dedicated sanitiser
rather than an encoder — DOMPurify on the client, or the
OWASP Java HTML Sanitizer on the server. Client-side code that builds DOM nodes should set
textContent rather than assembling an innerHTML string; DOM-based XSS involves no server
round trip, so no server-side control can catch it.
Error pages
Section titled “Error pages”An uncaught exception should produce a short, generic message. A stack trace on the response tells an attacker the framework, the library versions and often the file layout.
In Spring Boot, disable the Whitelabel page and supply a controller that renders only the attributes you choose:
debug=falseserver.error.whitelabel.enabled=falseserver.error.include-stacktrace=neverserver.error.include-message=neverimport jakarta.servlet.http.HttpServletRequest;import org.springframework.boot.web.error.ErrorAttributeOptions;import org.springframework.boot.web.servlet.error.ErrorAttributes;import org.springframework.boot.web.servlet.error.ErrorController;import org.springframework.stereotype.Controller;import org.springframework.ui.Model;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.context.request.ServletWebRequest;
import java.util.Map;
@Controllerpublic class CustomErrorController implements ErrorController {
private final ErrorAttributes errorAttributes;
public CustomErrorController(ErrorAttributes errorAttributes) { this.errorAttributes = errorAttributes; }
@RequestMapping("${server.error.path:/error}") String error(HttpServletRequest request, Model model) { Map<String, Object> attributes = errorAttributes.getErrorAttributes( new ServletWebRequest(request), ErrorAttributeOptions.defaults()); model.addAttribute("status", attributes.get("status")); model.addAttribute("timestamp", attributes.get("timestamp")); return "error"; }}ErrorAttributeOptions.defaults() includes neither the stack trace nor the exception message.
The error template should render the status and a correlation identifier and nothing else — the
detail belongs in the server log, indexed by that identifier.
Note that ErrorController moved to org.springframework.boot.web.servlet.error in Spring Boot
2.0, getErrorPath() was removed in 2.5 in favour of the server.error.path property, and the
javax.servlet packages became jakarta.servlet in Spring Boot 3.0. Samples predating those
changes will not compile.
Debug output and logging
Section titled “Debug output and logging”Do not let a request parameter turn debugging on. An attacker who can set it gets the debug output written to logs they may be able to read, and a detailed picture of the system either way. Debug verbosity belongs in configuration that only the deployment can set:
package com.example.settings;
import lombok.Data;import org.springframework.beans.factory.annotation.Value;import org.springframework.stereotype.Component;
@Component@Datapublic class DebugSettings {
@Value("${debug.enabled:false}") private Boolean enabled;}Beyond that:
- Keep separate logging levels for test and production environments, and check the production
value as part of the deployment — a service that ships to production at
DEBUGwill log request bodies, configuration and sometimes credentials. In Spring Boot the property islogging.level.<logger>; there is nospring.debug.level. - Log internal identifiers, not email addresses, names or anything else that identifies a person. A log store rarely has the access controls the database has.
- Log too little and an attacker’s password changes and profile edits pass unnoticed; log everything and the entries that matter are buried. Log authentication outcomes, authorisation failures and changes to credentials or permissions, with enough context to trace the actor.
- Use a distinct logger for debug output so it can be raised and lowered without touching the audit trail.
Session cookies
Section titled “Session cookies”Set HttpOnly on the session cookie so that script cannot read it. If the application is
compromised by XSS, HttpOnly is what keeps the session identifier out of the attacker’s hands:
servletContext.getSessionCookieConfig().setHttpOnly(true);servletContext.getSessionCookieConfig().setSecure(true);Explicitly setting setHttpOnly(false) — which appears in old samples — makes the cookie
readable by any script on the page and should never survive review. Add SameSite=Lax or
Strict as the CSRF countermeasure that does not depend on a token.
Deserialization
Section titled “Deserialization”Encryption does not prevent a deserialization attack. Wrapping a CipherInputStream around
an ObjectInputStream still hands a byte stream to readObject(), which instantiates whatever
classes the payload names before any application code sees the result. An InvalidClassException
raised by a corrupt stream is a decoding failure, not a security control.
Two related pieces of old advice are worth naming because they still circulate. A transformation
string of "Blowfish" alone names no mode and no padding, so the provider substitutes its
default — ECB in SunJCE — which encrypts identical plaintext blocks to identical ciphertext
blocks and leaks the structure of the object graph. Blowfish’s 64-bit block is also too small for
modern data volumes. Where a serialized object genuinely must be protected in transit or at rest,
use AES/GCM/NoPadding with a 256-bit key and a unique nonce per message, which authenticates
the ciphertext as well as concealing it. That is confidentiality and integrity; it is still not
input validation.
The control that stops a gadget chain is an allow-list of the classes the stream may resolve. There are three ways to apply one, in roughly descending order of preference.
A JDK serialization filter (java.io.ObjectInputFilter, available since Java 9) applies
without changing the deserialization call sites. Set it per stream, or process-wide:
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter("com.example.dto.*;java.base/*;!*");ObjectInputFilter.Config.setSerialFilter(filter);The same pattern can be supplied at launch as -Djdk.serialFilter=.... The trailing !* is what
makes it an allow-list: everything not named is rejected.
Apache Commons IO’s ValidatingObjectInputStream takes the allow-list at the call site. Use
the builder — the public constructors are deprecated:
try (InputStream in = Files.newInputStream(path); ValidatingObjectInputStream ois = ValidatingObjectInputStream.builder() .accept(UserInfoDto.class) .setInputStream(in) .get()) { return (UserInfoDto) ois.readObject();}A subclass overriding resolveClass is the portable fallback where neither of the above is
available:
public class SingleTypeObjectInputStream extends ObjectInputStream {
public SingleTypeObjectInputStream(InputStream in) throws IOException { super(in); }
@Override protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { if (!desc.getName().equals(EmployeeRequest.class.getName())) { throw new InvalidClassException("Unauthorized deserialization attempt", desc.getName()); } return super.resolveClass(desc); }}The best option remains not to deserialize attacker-influenced Java object streams at all. A data format with no code-execution semantics — JSON or Protocol Buffers bound to a declared type — removes the class of bug rather than filtering it.
Keys and certificates
Section titled “Keys and certificates”PKCS #12 is the archive format used to store cryptographic objects in a single file, most often to bundle a private key with its X.509 certificate or to carry a complete chain of trust. The file may be encrypted and signed, and so may the individual containers inside it, called SafeBags; predefined SafeBag types hold certificates, private keys and CRLs, and one general type holds anything else. A PKCS #12 file is protected only by its password, so treat it as a secret in its own right: keep it out of the source tree and out of the container image, and load it from the same secret store as the database credentials.
Known vulnerable components
Section titled “Known vulnerable components”Fetch libraries from official repositories over TLS and prefer signed artefacts. Run a software composition analysis tool as part of the build so a dependency with a published vulnerability fails the build rather than reaching production — OWASP Dependency-Check has Gradle and Maven plugins and can be configured to fail on a CVSS score above a chosen threshold. Choose that threshold deliberately: scores are defined in the CVSS specification, and a failure threshold set too high means the gate never fires.
Keeping dependencies current matters as much as scanning them. A library pinned years back accumulates known issues faster than any one of them gets triaged.
Email header injection
Section titled “Email header injection”A mail library that writes an unvalidated string into a message header lets an attacker who
controls that string inject a line feed and start a header of their own — adding recipients,
rewriting the sender or attaching content. The subject line is the usual entry point. Strip CR
and LF from any user-supplied value before it reaches setSubject() or an address field, and use
a current, maintained mail library rather than a version pinned years ago.
Automated abuse
Section titled “Automated abuse”Where an endpoint can be driven at machine speed — sign-up, password reset, a search that is expensive to serve — rate limiting is the primary control and a CAPTCHA such as reCAPTCHA is a supplement. Loading a third-party script places trust in that origin: pin it to the vendor’s documented URL, serve it over TLS, and account for it in the Content Security Policy.