Skip to content

05 · Security Best Practices

Module 1 covered authentication and authorization. This module covers the layer beneath that: making sure the data flowing into your application can't be weaponized against it, and that secrets never end up somewhere an attacker can read them.

Input validation with Bean Validation

Never trust data from a client. Bean Validation (jakarta.validation) declares constraints directly on a DTO, and Spring enforces them automatically when the DTO is a @Valid controller parameter.

// CreateUserRequest.java
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record CreateUserRequest(
    @NotBlank(message = "Name is required")
    String name,

    @Email(message = "Must be a valid email address")
    @NotBlank
    String email,

    @Size(min = 8, max = 100, message = "Password must be 8-100 characters")
    String password
) {}
// UserController.java
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping
    public UserResponse createUser(@Valid @RequestBody CreateUserRequest request) {
        // If validation fails, Spring throws MethodArgumentNotValidException
        // before this method body ever runs, and returns 400 Bad Request.
        return userService.create(request);
    }
}

A @RestControllerAdvice (see Module 10) can turn the resulting MethodArgumentNotValidException into a clean JSON error body listing every failed field instead of a raw stack trace.

SQL injection — always use parameter binding

SQL injection happens when untrusted input is concatenated directly into a query string. Both JDBC's PreparedStatement and Spring Data's query methods bind parameters safely by default — the fix is simply "never build SQL with string concatenation."

// VULNERABLE -- never do this
String query = "SELECT * FROM users WHERE email = '" + userInput + "'";
// userInput = "' OR '1'='1" turns this into a query that returns every row

// SAFE -- PreparedStatement treats userInput strictly as a value, never as SQL
String sql = "SELECT * FROM users WHERE email = ?";
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
    stmt.setString(1, userInput);
    ResultSet rs = stmt.executeQuery();
}

// SAFE -- Spring Data's @Param binding (see Module 1) is equally safe
@Query("SELECT u FROM User u WHERE u.email = :email")
Optional<User> findByEmail(@Param("email") String email);

Common vulnerabilities and mitigations

Vulnerability What it is Mitigation
SQL Injection Untrusted input executed as SQL Always use PreparedStatement / parameterized JPQL, never string-concatenate queries
XSS (Cross-Site Scripting) Untrusted input rendered as HTML/JS in a browser Escape output on render; rely on template engines' auto-escaping; set a Content-Security-Policy header
CSRF (Cross-Site Request Forgery) A tricked browser submits a request using the victim's session Use Spring Security's CSRF tokens for cookie-based sessions; not needed for stateless token-based APIs
Insecure Deserialization Deserializing untrusted data executes attacker-controlled code Avoid Java native serialization for untrusted input; validate/allow-list types when deserializing JSON into polymorphic types

Secrets management

Never hardcode credentials, API keys, or tokens in source code — they end up in git history forever, visible to anyone with repo access.

# application.properties -- BAD
spring.datasource.password=SuperSecret123!

# application.properties -- GOOD: placeholder resolved from the environment
spring.datasource.password=${DB_PASSWORD}
# The actual secret lives in the environment, not the repo
export DB_PASSWORD='SuperSecret123!'
java -jar app.jar
# docker-compose.yml -- secrets passed at runtime, not baked into the image
services:
  app:
    image: order-service:latest
    environment:
      DB_PASSWORD: ${DB_PASSWORD}   # pulled from the host's/.env's environment

For production systems beyond a single host, a dedicated secrets manager (HashiCorp Vault, AWS Secrets Manager, or a Kubernetes Secret mounted as a file) centralizes storage, rotation, and access-audit logging for credentials — the application fetches secrets at startup rather than having them baked into any config file at all.

OWASP Top 10

The OWASP Top 10 is the industry reference list of the most critical web application security risks — injection, broken authentication, sensitive data exposure, security misconfiguration, and others covered above among them. Treat it as a checklist to review against before shipping anything that accepts external input.

How It Actually Works

BCrypt-style password hashing is deliberately slow by design: it runs a Blowfish-derived key schedule through a configurable number of rounds (the "cost factor"), each doubling the work — the point is to make brute-forcing a stolen hash database computationally expensive per guess, unlike a fast hash (MD5/SHA-256) which an attacker can test at billions of guesses per second on commodity GPUs. The salt embedded in the BCrypt output isn't secret — its entire purpose is to force an attacker to redo the expensive computation per password even for identical passwords, defeating precomputed rainbow tables.

Spring Security's filter chain is a literal List<Filter> wrapping the servlet container's own filter mechanism — every request runs through an ordered chain (CSRF check, authentication, authorization) before your controller method is even reached, each filter able to short- circuit the chain and commit a response (a 401/403) without your application code ever running.

JWTs are signed, not encrypted, by default: the payload (claims) is just base64url-encoded JSON, readable by anyone who intercepts the token; the signature (HMAC or RSA/ECDSA over header+payload) only lets the server detect tampering — this is the mechanical reason putting secrets or PII directly in JWT claims is a real vulnerability, not a style nitpick.

Exercise

Add Bean Validation annotations to a RegisterDeviceRequest record with fields deviceId (must not be blank), ownerEmail (must be a valid email), and pin (must be exactly 4-6 characters). Wire it into a @PostMapping("/devices") controller method using @Valid. Then rewrite an imaginary vulnerable line "SELECT * FROM devices WHERE id = '" + id + "'" as a safe parameterized query using either PreparedStatement or a Spring Data @Query.