10 Java Security Mistakes I've Fixed for a Decade (With Code That Actually Works)

Protect your Java apps from common attacks. Learn 10 proven security patterns—from input validation to encryption—with real code examples you can apply today.

10 Java Security Mistakes I've Fixed for a Decade (With Code That Actually Works)

I have been writing Java code for over a decade. In that time I have seen the same security mistakes repeated over and over. The worst part is that most of these mistakes are easy to fix once you know what to look for. I want to share ten patterns that will protect your applications from the most common attacks. These are not theoretical ideas. They are concrete code changes you can apply today.

Let me start by saying something obvious but often ignored: every piece of data that comes into your system from outside must be treated as a weapon. Users, APIs, even internal services can send harmful input. The safest way to handle this is to define exactly what you expect and reject everything else. This is called a whitelist approach.

I remember a project where we accepted usernames and only filtered out a few characters like apostrophes and semicolons. A user sent a carefully crafted string with encoded characters that bypassed our blacklist. That string contained SQL injection commands. We were lucky because the database user had limited permissions. But it could have been a disaster.

public class InputValidator {
    public String sanitizeUsername(String input) {
        // Only allow alphanumeric characters and underscores
        if (!input.matches("^[a-zA-Z0-9_]{3,20}$")) {
            throw new IllegalArgumentException("Invalid username format");
        }
        return input;
    }
}

The pattern is simple. Use a regular expression that describes exactly what is allowed. If the input does not match, reject it immediately. Do not try to clean it. Do not try to escape dangerous characters. Just throw it away. This approach works for names, addresses, dates, and any other field that has a clear format. For free text fields like comments, you still need to encode output when displaying them, but you can still limit length and character range.

Now let me talk about the king of all vulnerabilities: SQL injection. I have lost count of how many times I have seen code like this:

String query = "SELECT * FROM users WHERE username = '" + username + "'";
Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(query);

This is a direct invitation for attackers. They can close the string with a single quote and add malicious SQL commands. The fix is painfully simple: use a prepared statement.

public User findUserByEmail(String email) {
    String sql = "SELECT * FROM users WHERE email = ?";
    try (PreparedStatement stmt = connection.prepareStatement(sql)) {
        stmt.setString(1, email);
        ResultSet rs = stmt.executeQuery();
        // Process result
    }
}

The question mark placeholder tells the database driver to treat the value as data, not as part of the SQL command. The driver handles all the escaping. This works with any database. If you are using an ORM like Hibernate, use named parameters in your queries. Never, ever concatenate strings to build SQL. I do not care how simple the query looks. Just do not do it.

Passwords are another area where developers often cut corners. I have seen companies store passwords in plaintext. I have seen them use MD5 because it is fast. Fast is exactly what you do not want. Attackers can try billions of hashes per second with MD5. You need a hash function that is deliberately slow and uses a random salt for each password.

public String hashPassword(char[] password) {
    return BCrypt.withDefaults().hashToString(12, password);
}

public boolean verifyPassword(char[] password, String storedHash) {
    return BCrypt.verifyer().verify(password, storedHash).verified;
}

BCrypt is a good choice. The number 12 is the cost factor. On my machine it takes about one second to hash a password. That is fine for a login process. For an attacker trying billions of combinations it makes the job incredibly expensive. Always use a char array instead of a String for passwords so you can clear the array after use.

Now let me talk about authentication. Session-based authentication still works, but many modern applications prefer stateless tokens. JSON Web Tokens (JWT) are common. The key is to keep the token small, sign it with a strong secret, and set a short expiration time.

I once inherited a system where tokens expired after 24 hours and contained the user’s full credit card number. The tokens were not even encrypted. Anyone who intercepted a token could read the card number. Do not do that.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .oauth2ResourceServer(oauth2 -> oauth2
            .jwt(jwt -> jwt
                .jwtAuthenticationConverter(jwtAuthenticationConverter())
            )
        );
    return http.build();
}

public String generateToken(String username, String role) {
    return Jwts.builder()
        .subject(username)
        .claim("role", role)
        .issuedAt(new Date())
        .expiration(new Date(System.currentTimeMillis() + 900_000)) // 15 minutes
        .signWith(getSigningKey())
        .compact();
}

Notice I only put the username and role in the token. No secrets, no personal data. The expiration is 15 minutes. For longer sessions, use a refresh token that can be revoked. The signing key should be stored securely, not hardcoded in the source code.

Encryption at rest is essential when you store sensitive data like credit card numbers, social security numbers, or health information. Even if an attacker steals your database, they cannot read the encrypted data without the key.

I had to encrypt patient data for a healthcare app. We used AES in GCM mode, which provides both confidentiality and authenticity.

public byte[] encrypt(byte[] plainText, SecretKey key) throws Exception {
    Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
    cipher.init(Cipher.ENCRYPT_MODE, key);
    byte[] iv = cipher.getIV();
    byte[] encrypted = cipher.doFinal(plainText);
    // Combine IV and ciphertext for storage
    ByteBuffer buffer = ByteBuffer.allocate(iv.length + encrypted.length);
    buffer.put(iv);
    buffer.put(encrypted);
    return buffer.array();
}

The IV (initialization vector) is randomly generated each time. You need to store it alongside the ciphertext so you can decrypt later. Keys must be managed separately. Use a key management service or a hardware security module. Never store keys in the same database as the data.

Authorization is where many applications fail. They scatter permission checks across many methods, and someone always forgets one. The best pattern is to use a central authorization mechanism that checks roles or permissions before executing any sensitive operation.

Spring Security offers method-level security with annotations like @PreAuthorize. I use this everywhere.

@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/api/admin/users")
public ResponseEntity<User> createUser(@Valid @RequestBody CreateUserRequest request) {
    return ResponseEntity.ok(userService.create(request));
}

@PreAuthorize("@securityService.isOwner(#orderId)")
@GetMapping("/api/orders/{orderId}")
public ResponseEntity<Order> getOrder(@PathVariable Long orderId) {
    return ResponseEntity.ok(userService.findById(orderId));
}

The first example only allows users with the ADMIN role to create users. The second example uses a custom method isOwner that checks if the current user owns the order. I write the isOwner method in a separate bean so the logic is testable and reusable.

Deserialization is a hidden danger. Java serialization can be exploited to execute arbitrary code. Attackers craft objects that trigger dangerous methods when deserialized. If you must use Java serialization, always set a filter.

ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
    "java.lang.*;com.example.safe.*;!*"
);
try (ObjectInputStream ois = new ObjectInputStream(inputStream)) {
    ois.setObjectInputFilter(filter);
    Object obj = ois.readObject();
}

This filter only allows classes from java.lang and com.example.safe. Everything else is rejected. But even better, avoid Java serialization altogether. Use JSON or Protocol Buffers. They are not vulnerable to deserialization attacks.

Communication security is non-negotiable. Every external connection must use TLS. I configure my HTTP clients and servers to only accept TLS 1.3 with strong cipher suites.

SSLContext sslContext = SSLContext.getInstance("TLSv1.3");
sslContext.init(keyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), new SecureRandom());

HttpClient client = HttpClient.newBuilder()
    .sslContext(sslContext)
    .sslParameters(new SSLParameters(new String[]{"TLS_AES_128_GCM_SHA256"}, new String[]{"TLSv1.3"}))
    .build();

Always validate certificates. Do not disable hostname verification, even in development. For internal services, mutual TLS adds an extra layer of security by requiring both sides to present certificates.

Cross-Site Request Forgery (CSRF) is an attack where a malicious website makes a user’s browser send unwanted requests to a web application where the user is authenticated. Traditional web applications need CSRF protection. Stateless APIs using bearer tokens do not.

If you are building a server-rendered application with Spring Security, keep CSRF protection enabled.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
        );
    return http.build();
}

The token is stored in a cookie that JavaScript can read. Your forms include this token in a hidden field or header. When the server receives the request, it checks the token. If it does not match, the request is rejected.

Finally, logging. Security logs are vital for detecting attacks, but they can become a liability if they contain sensitive information. I have seen logs that print entire SQL queries with passwords in plaintext.

public class SecurityAuditLogger {
    private static final Logger log = LoggerFactory.getLogger(SecurityAuditLogger.class);

    public void loginFailed(String username) {
        log.warn("Login failed for user: {}", sanitize(username));
    }

    public void accessDenied(String userId, String resource) {
        log.info("Access denied for user: {} to resource: {}", userId, resource);
    }

    private String sanitize(String input) {
        // Remove line breaks and truncate
        return input.replaceAll("[\\n\\r]", "_").substring(0, Math.min(input.length(), 50));
    }
}

Only log what is necessary. Never log passwords, credit card numbers, or tokens. Sanitize input to remove line breaks that could allow log injection. Store logs in a secure location that cannot be modified by attackers.

These ten patterns are not a magic bullet. Security is a process, not a product. You need to review your code regularly, run static analysis tools, and perform penetration tests. But if you start with these patterns, you will close the most common doors that attackers use to break in.

I have seen teams transform their security posture by adopting these practices. They sleep better at night. You can too. Start with one pattern today. Validate your inputs. Use prepared statements. Hash your passwords. The rest will follow.


// Keep Reading

Similar Articles