What This Page Covers
This page is about secure coding practices that prevent an application from being exploitable — injection, XSS, path traversal, secrets handling. It deliberately doesn't re-cover the identity and session mechanics already detailed in Security & Authentication (Password Hashing, Session Management, JWT, OAuth 2.0) — where this page touches those topics, it links out rather than repeating them.
Input Validation
Never trust user input. Validate at every system boundary — and validate by defining what is allowed, not by trying to enumerate what isn't.
Whitelisting Over Blacklisting
// GOOD — whitelist: define the allowed shape explicitly
public String sanitizeUsername(String username) {
if (username == null || username.isBlank()) {
throw new ValidationException("Username required");
}
if (!username.matches("^[a-zA-Z0-9_]{3,20}$")) {
throw new ValidationException("Invalid username format");
}
return username;
}
// Bean Validation — declarative, enforced automatically via @Valid
public record CustomerRegistration(
@NotBlank @Size(min = 3, max = 20) @Pattern(regexp = "^[a-zA-Z0-9_]+$")
String username,
@Email @NotBlank
String email,
@Size(min = 12, max = 100)
String password
) { }
SQL Injection Prevention
// BAD — string-concatenated SQL is directly exploitable
public Customer findByUsername(String username) {
String sql = "SELECT * FROM customers WHERE username = '" + username + "'";
// Input: ' OR '1'='1' --
// Becomes: SELECT * FROM customers WHERE username = '' OR '1'='1' --'
// Returns every row in the table.
return jdbcTemplate.queryForObject(sql, customerRowMapper);
}
// GOOD — parameterized query; the driver, not string concatenation,
// handles the substitution safely
public Customer findByUsername(String username) {
String sql = "SELECT * FROM customers WHERE username = ?";
return jdbcTemplate.queryForObject(sql, customerRowMapper, username);
}
// GOOD — JPQL named parameters, same protection
@Query("SELECT c FROM Customer c WHERE c.username = :username")
Optional<Customer> findByUsername(@Param("username") String username);
Cross-Site Scripting (XSS) Prevention
// BAD — user content rendered without escaping
@GetMapping("/profile")
public String profile(Model model) {
model.addAttribute("bio", customer.getBio()); // could contain <script>...</script>
return "profile";
}
// GOOD — Thymeleaf's th:text auto-escapes by default
// <p th:text="${bio}"></p> → renders the script tag as visible text, not executable markup
// For raw API responses — encode explicitly
public String sanitizeForHtml(String input) {
return HtmlUtils.htmlEscape(input);
}
X-XSS-Protection header is dead — CSP is the real defense nowOlder guidance recommended enabling the browser's built-in
X-XSS-Protection filter. Modern browsers have
actually removed this feature — Chrome
dropped it in 2019, and it's no longer a meaningful
defense in any current browser, since the heuristic filter
it powered had its own exploitable bugs and was replaced
by better mechanisms. Content Security Policy is what
actually stops injected scripts from executing today:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.headers(headers -> headers
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; script-src 'self'")
)
);
return http.build();
}
With script-src 'self', even a successfully
injected <script> tag simply won't
execute — the browser refuses to run inline or
externally-hosted scripts that violate the policy,
regardless of whether the escaping above was somehow
bypassed. Treat CSP as the defense-in-depth layer, not
output escaping as the only layer.
Password Security
The hashing mechanics (BCrypt/Argon2, cost factors, why
equals() is never acceptable for a password
comparison) are covered in full in
Password
Hashing. The one thing worth updating here specifically:
what a password policy should actually require.
NIST SP 800-63B — the current US federal digital identity
guideline, and the reference point most modern security
teams follow — explicitly recommends against
mandatory composition rules like "must contain an
uppercase letter and a digit." In practice, these rules
produce a small, predictable set of user
behaviors — Password1!, appending
123 to a familiar word — that add
negligible real entropy while measurably increasing how
often users write passwords down or reuse a slightly
modified pattern across sites. The current guidance
instead favors: a generous minimum length (12+
characters), no forced periodic rotation without
evidence of compromise, and — the check that actually
matters — screening new passwords against a database of
known-breached passwords.
// OUTDATED — composition rules NIST now recommends against
public void validatePassword(String password) {
if (password.length() < 8) {
throw new ValidationException("Password must be at least 8 characters");
}
if (!password.matches(".*[A-Z].*")) {
throw new ValidationException("Password must contain uppercase letter");
}
// This rule set is exactly what pushes users toward "Password1!"
}
// CURRENT GUIDANCE — length and breach screening, not composition rules
public void validatePassword(String password) {
if (password.length() < 12) {
throw new ValidationException("Password must be at least 12 characters");
}
if (breachedPasswordChecker.isKnownBreached(password)) {
throw new ValidationException("This password has appeared in a known data breach — choose another");
}
// No forced uppercase/digit/symbol composition requirement
}
Secrets Management
// BAD — hardcoded credential, committed to Git forever (see CI/CD's
// coverage of secret scanning and history rewriting)
String apiKey = "sk_live_abc123xyz789";
// GOOD — environment variable, injected at deploy time, never committed
String apiKey = System.getenv("API_KEY");
// GOOD — Spring externalized configuration
@Value("${api.secret-key}")
private String apiSecretKey;
@Value@Value("${vault.database.password}") on its
own is ordinary property injection — it doesn't actually
talk to HashiCorp Vault by itself. A genuine Vault
integration requires the
spring-cloud-starter-vault-config dependency
and a bootstrap.yml/application.yml
pointing at the Vault server and authentication method
(token, AppRole, Kubernetes service account); once that's
configured, Spring Cloud Vault populates the property
source Vault-backed automatically, and the
@Value injection above works unchanged. The
@Value annotation is correct either way — what
makes it "Vault" is the property source behind it, not the
annotation itself.
// Never log sensitive data — see Logging Frameworks for the full guidance
// BAD
log.info("Customer {} logged in with password {}", username, password);
// GOOD
log.info("Customer {} logged in", username);
// Clear sensitive data from memory when done — char[] can be overwritten,
// a String cannot (Strings are immutable and may live in memory
// until GC, with no way to force-clear the contents)
char[] password = getPasswordFromRequest();
try {
authenticate(password);
} finally {
Arrays.fill(password, '\0');
}
CSRF Protection
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) // cookie-based, for an SPA front end
);
return http.build();
}
CSRF exploits ambient authentication — a
browser automatically attaches a session cookie to every
request to a domain, including ones triggered by a
malicious third-party page the victim happens to have
open. A stateless API authenticated via a JWT in an
Authorization header has no such ambient
credential: the malicious page has no way to make the
victim's browser attach a header it doesn't control, since
headers (unlike cookies) are never sent automatically by
the browser cross-origin. Disabling CSRF protection for
this specific case isn't skipping a safeguard — it's
correctly recognizing that the attack CSRF protection
exists to stop doesn't apply to header-based auth in the
first place. See JWT
for where the token itself needs to live to preserve this
property (never in a plain, non-HttpOnly
cookie, which would reintroduce ambient auth).
Secure Random Numbers
// BAD — java.util.Random is a predictable, seeded PRNG — never for security
Random random = new Random();
String token = String.valueOf(random.nextLong()); // an attacker who observes enough
// output can predict future values
// GOOD — cryptographically secure, for tokens, session IDs, reset links
public String generateSecureToken() {
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
Secure File Handling
// BAD — path traversal: filename isn't validated against the intended directory
public File getFile(String filename) {
// Input: "../../../etc/passwd"
return new File("/uploads/" + filename);
}
// GOOD — resolve and verify the result stays inside the intended base directory
public Path getFile(String filename) {
Path basePath = Paths.get("/uploads").toAbsolutePath().normalize();
Path filePath = basePath.resolve(filename).normalize();
if (!filePath.startsWith(basePath)) {
throw new SecurityException("Invalid file path");
}
return filePath;
}
// Validate uploads: check content, size, and generate a new filename —
// never trust a client-supplied filename or Content-Type at face value
public void handleUpload(MultipartFile file) {
if (!ALLOWED_TYPES.contains(file.getContentType())) {
throw new ValidationException("File type not allowed");
}
if (file.getSize() > MAX_FILE_SIZE) {
throw new ValidationException("File too large");
}
String safeFilename = UUID.randomUUID() + getExtension(file); // never store the client's own filename
}
Method-Level Access Control — OWASP's #1 Risk
Broken Access Control has topped the OWASP Top 10 in recent editions — and the most common real-world cause isn't a missing login check, it's an authenticated user reaching data or an action that belongs to someone else.
// BAD — authenticated, but never checks whether THIS order belongs to
// the calling customer
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable Long id) {
return orderRepository.findById(id).orElseThrow();
// Any logged-in customer can read any other customer's order by
// simply changing the id in the URL — this is an IDOR (Insecure
// Direct Object Reference), the textbook Broken Access Control case
}
// GOOD — enforce ownership explicitly, not just "is logged in"
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable Long id, @AuthenticationPrincipal CustomerPrincipal principal) {
Order order = orderRepository.findById(id).orElseThrow();
if (!order.getCustomerId().equals(principal.getCustomerId())) {
throw new AccessDeniedException("Not your order");
}
return order;
}
// Or declaratively, for role-based rules
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/orders/{id}")
public void deleteOrder(@PathVariable Long id) { /* ... */ }
See Authentication vs Authorization for the underlying distinction — this is the authorization half of that distinction going wrong in practice.
Security Headers
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.headers(headers -> headers
.frameOptions(frame -> frame.deny()) // prevent clickjacking
.contentTypeOptions(content -> {}) // prevent MIME-sniffing
.httpStrictTransportSecurity(hsts -> hsts // force HTTPS on every future visit
.maxAgeInSeconds(31536000)
.includeSubDomains(true)
)
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; script-src 'self'")
)
// X-XSS-Protection intentionally omitted — see Section 3
);
return http.build();
}
See HTTPS & SSL/TLS for the full mechanics HSTS depends on.
OWASP Top 10 — Quick Reference and Where It's Covered
- Broken Access Control — Section 9 above, and Authentication vs Authorization
- Injection — Section 2 above; parameterized queries always
- Cryptographic Failures — HTTPS & SSL/TLS, Password Hashing
- Insecure Design — SOLID Principles, threat modeling before code, not after
- Security Misconfiguration — secure defaults, remove unused features/endpoints
- Vulnerable Components — CI/CD's coverage of dependency scanning and Action pinning
- Identification and Authentication Failures — Session Management, JWT
- Software and Data Integrity Failures — CI/CD's supply-chain pinning coverage
- Logging and Monitoring Failures — Logging Frameworks
- Server-Side Request Forgery — validate and allowlist any server-side outbound URL built from user input, the same whitelisting principle as Section 1
Best Practices and Common Pitfalls
✅ Do
- Validate by whitelisting the allowed shape, not by trying to blacklist known-bad input
- Use parameterized queries and Bean Validation everywhere user input reaches a data store
- Rely on CSP as the real XSS defense, not the removed
X-XSS-Protectionheader - Require length over composition for passwords, and screen against known-breached password lists — per current NIST guidance
- Check resource ownership explicitly on every request that returns or modifies a specific record — "authenticated" is not the same as "authorized for this specific resource"
❌ Don't
- Don't build SQL by string concatenation, ever, regardless of how "trusted" the input source seems
- Don't enforce password composition rules (mandatory uppercase/digit/symbol) — current guidance actively discourages them
- Don't trust a client-supplied filename or
Content-Typeat face value for uploads - Don't assume disabling CSRF for a JWT API is a shortcut — it's correct specifically because header-based auth isn't ambient
- Don't return or modify a resource by ID without verifying the authenticated caller actually owns or is authorized for that specific resource
Interview Questions
Q: Why is a parameterized query safe from SQL injection when string concatenation isn't?
A parameterized query sends the SQL structure and the
user-supplied value to the database as two separate things —
the database driver treats the parameter strictly as data,
never as part of the SQL syntax itself. String concatenation
merges user input directly into the SQL text, so
attacker-supplied SQL syntax becomes part of the query the
database actually executes.
Q: What is an IDOR (Insecure Direct Object Reference)?
It's when an application returns or modifies a resource based
purely on an ID supplied in the request, without checking
whether the authenticated caller is actually authorized to
access that specific resource — for example, any logged-in
user being able to view any order by simply changing the ID in
the URL.
Q: Why should you never store a client-uploaded file under its original filename?
A client-supplied filename could be crafted for a path
traversal attack (../../etc/passwd) or could
collide with or overwrite another file. Generating a new,
random filename (e.g., a UUID) and storing the original name
only as metadata avoids both risks.
Q: Your team enforces a strict password composition policy (uppercase, digit, special character, 8-char minimum) and considers this strong security. Explain why current guidance argues this is actually weaker than a simpler length-based policy.
Composition rules constrain the space of passwords users are
willing to remember far more than they constrain the space an
attacker has to guess — faced with "must contain an uppercase
letter and a digit," the overwhelming majority of users
produce a small set of predictable transformations of a
familiar word (capitalize the first letter, append a digit or
two, append a common symbol), which attackers' password-
cracking wordlists already model extensively. The rule adds
little real entropy while measurably increasing the rate at
which users write the password down, reuse a slightly
modified version across multiple sites, or use a password
manager to route around the friction (not itself bad, but
evidence the rule is friction rather than protection). NIST
SP 800-63B's current position — favor a generous minimum
length and check candidates against a database of previously
breached passwords — targets the actual attack that matters in
practice: credential-stuffing using passwords already known
from other breaches, which no amount of composition
complexity in a *new* password defends against, and which a
breach-database check catches directly regardless of how
"complex" the password looks.
Q: A team disables CSRF protection for a REST API authenticated via JWT in the Authorization header, and a colleague flags this as a security regression. Are they right?
Generally no, provided the JWT is genuinely only ever sent via
an explicit Authorization header set by
JavaScript, and never also accepted from a cookie. CSRF as an
attack class depends entirely on ambient
authentication — a credential the browser attaches to a
request automatically, purely because the request targets a
given domain, regardless of which page on the internet
triggered it. A cookie is ambient by design; an
Authorization header is not — no mechanism exists
for a malicious third-party page to force the victim's browser
to attach an arbitrary header to a cross-origin request the
page itself constructs. Because the precondition CSRF exploits
(an automatically-attached credential) doesn't exist for
header-based JWT auth, disabling CSRF protection here isn't
removing a safeguard against a real risk — it's correctly
recognizing the risk doesn't apply to this specific
authentication mechanism. The colleague would be right to
flag it only if the JWT is also stored in a
non-HttpOnly cookie or otherwise made ambient,
which would reintroduce the exact precondition CSRF
protection exists for.
Q: Content Security Policy is described as "defense in depth" against XSS rather than the only defense. Explain concretely what CSP does and doesn't protect against, and why output escaping is still necessary alongside it.
CSP's script-src directive controls which
sources of JavaScript the browser is willing to execute at
all — with script-src 'self', even a
successfully injected inline <script> tag
simply won't run, because the browser refuses to execute
script content that violates the declared policy. What CSP
does not do is prevent the injection itself, or
protect against attacks that don't rely on script execution —
HTML injection that defaces a page's visual content, or an
attribute-based injection that manipulates a link's
destination, can still succeed under a strict CSP, because
neither requires executing attacker-controlled JavaScript.
CSP also depends entirely on being configured correctly and
consistently across every response; a single page or endpoint
missing the header, or a policy loosened for a legitimate but
careless reason (a wildcard added to unblock a third-party
widget), reopens the exact hole CSP was meant to close. Output
escaping remains the primary defense because it prevents the
injection from succeeding in the first place, regardless of
what policy is or isn't correctly applied on any given
response — CSP is what limits the blast radius on the
response where escaping nonetheless fails.