Security Best Practices

Secure coding against injection, XSS, and path traversal — the practices that complement (not repeat) the identity and session mechanics covered in Security & Authentication

← Back to Index

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);
}
The X-XSS-Protection header is dead — CSP is the real defense now

Older 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.

Complexity rules (uppercase + digit + symbol) are now discouraged, not recommended, by NIST

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;
A real Vault integration needs the Vault dependency and config — not just @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();
}
Why stateless JWT APIs commonly disable CSRF protection — and why that's actually correct, not a shortcut

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

Where each risk is actually addressed on this site
  • Broken Access Control — Section 9 above, and Authentication vs Authorization
  • Injection — Section 2 above; parameterized queries always
  • Cryptographic FailuresHTTPS & SSL/TLS, Password Hashing
  • Insecure DesignSOLID Principles, threat modeling before code, not after
  • Security Misconfiguration — secure defaults, remove unused features/endpoints
  • Vulnerable ComponentsCI/CD's coverage of dependency scanning and Action pinning
  • Identification and Authentication FailuresSession Management, JWT
  • Software and Data Integrity FailuresCI/CD's supply-chain pinning coverage
  • Logging and Monitoring FailuresLogging 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-Protection header
  • 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-Type at 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

🎓 Junior level

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.

🔥 Senior level

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.