Security in software is a conversation we need to keep having. It’s not a feature you can bolt on at the end, but a way of thinking that needs to be present from the first line of code. In my work with Java applications, I’ve found that many security problems stem from a few common, addressable issues. Let’s walk through some practical steps you can take to make your Java applications more resistant to attack. Think of these not as rules, but as essential habits to develop.
The first and most important habit is to never trust data from outside your application. Every piece of information—a form field, a file upload, an API header—should be treated with suspicion until you’ve checked it thoroughly. This means validating for more than just null. You need to define the exact shape and content of what you expect.
For example, if a username should only contain letters, numbers, and underscores and be between 3 and 20 characters, enforce that strictly at the point where the data enters your system. Doing this early stops bad data from traveling deep into your logic where it can cause more harm.
public void registerUser(String inputUsername) {
// First, check for null or empty
if (inputUsername == null || inputUsername.trim().isEmpty()) {
throw new ValidationException("A username is required.");
}
// Define the exact pattern you accept. This is a whitelist approach.
String safePattern = "^[a-zA-Z0-9_]{3,20}$";
if (!inputUsername.matches(safePattern)) {
throw new ValidationException("Username can only contain 3-20 letters, numbers, or underscores.");
}
// Only now is the input considered safe to use.
User newUser = new User(inputUsername);
userRepository.save(newUser);
}
A classic and dangerous mistake is piecing together database queries by joining strings. It might seem straightforward, but it opens a direct path for attackers to manipulate your database. The solution is simple and should always be used: prepared statements.
When you use a prepared statement, you give the database a template for your query first. The user’s data is supplied later as a distinct parameter. The database engine understands that this parameter is data, not part of the command structure, so it can’t be used to change the query’s intent.
// This is risky and should never be done.
String dangerousQuery = "SELECT email FROM customers WHERE id = " + userSuppliedId;
Statement riskyStatement = connection.createStatement();
riskyStatement.executeQuery(dangerousQuery); // An attacker could inject SQL here.
// This is the secure way.
String safeQuery = "SELECT email FROM customers WHERE id = ?";
PreparedStatement safeStatement = connection.prepareStatement(safeQuery);
safeStatement.setInt(1, userSuppliedId); // The data is safely inserted as a value.
ResultSet results = safeStatement.executeQuery();
How you handle passwords is a fundamental measure of your application’s security. Storing a password in a way that can be reversed is a serious failure. Passwords must be hashed using a strong, purpose-built algorithm. These algorithms are slow by design, which makes attacking them very difficult.
I always reach for a well-tested library to handle this. The work of generating a secure random salt and applying multiple hashing rounds is handled for you. Your only job is to feed it the password and store the resulting hash string.
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
public class PasswordService {
// A higher strength value (like 12) makes the hash slower to compute and harder to crack.
private BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(12);
public String createSecureHash(String plainTextPassword) {
return encoder.encode(plainTextPassword);
}
public boolean verifyPassword(String plainTextAttempt, String storedHash) {
return encoder.matches(plainTextAttempt, storedHash);
}
}
// Usage
PasswordService pwdService = new PasswordService();
String hash = pwdService.createSecureHash("userPassword123");
// Store 'hash' in the database.
// Later, during login:
boolean isCorrect = pwdService.verifyPassword("attemptedPassword", hash);
Secrets like API keys and database passwords don’t belong in your source code. I’ve seen too many projects where a database URL and password are hardcoded in a .java file. If that code is ever shared, the secret is immediately exposed. Instead, these values should be provided to the application from a secure location when it runs.
During development, this can be a local file that is never committed to git. In production, use environment variables or a dedicated secrets manager provided by your cloud platform. Your code should fetch the secret at startup.
public class DatabaseConfig {
public Connection getConnection() throws SQLException {
// Fetch credentials from the environment, not from code.
String dbUrl = System.getenv("DATABASE_URL");
String dbUser = System.getenv("DB_USER");
String dbPass = System.getenv("DB_PASSWORD");
if (dbUrl == null || dbUser == null) {
throw new IllegalStateException("Database configuration is missing from the environment.");
}
Properties connectionProps = new Properties();
connectionProps.put("user", dbUser);
connectionProps.put("password", dbPass);
return DriverManager.getConnection(dbUrl, connectionProps);
}
}
This next idea is about limiting the blast radius. It’s called the principle of least privilege. Your application should run using accounts and permissions that are just powerful enough to do its job, and no more. If a hacker compromises your application, they inherit these limited permissions.
Don’t connect to your database with a root or sa account. Create a specific user that only has permission to SELECT, INSERT, and UPDATE on the tables it needs. It should never have the ability to DROP tables or GRANT permissions to others. Apply the same thinking to file system access.
// Example of setting restricted file permissions (Linux/macOS)
Path importantFile = Paths.get("/app/data/config.yml");
// Set permissions so only the owner can read/write, and the group can only read.
Set<PosixFilePermission> perms = new HashSet<>();
perms.add(PosixFilePermission.OWNER_READ);
perms.add(PosixFilePermission.OWNER_WRITE);
perms.add(PosixFilePermission.GROUP_READ);
// No permissions for OTHERS are added.
Files.setPosixFilePermissions(importantFile, perms);
Logs are incredibly useful for debugging, but they can accidentally become a source of data leaks. It’s surprisingly easy to log a credit card number, a social security number, or a session token by mistake. Once it’s in a log file, that sensitive data might be exposed to other systems or personnel.
You need a proactive strategy to clean data before it’s written to logs. I often create a simple sanitization utility that searches for patterns matching sensitive data and replaces them with placeholder text.
public class LogSanitizer {
private static final Pattern CREDIT_CARD_PATTERN = Pattern.compile("\\d{4}[ -]?\\d{4}[ -]?\\d{4}[ -]?\\d{4}");
private static final Pattern SSN_PATTERN = Pattern.compile("\\d{3}-\\d{2}-\\d{4}");
public static String clean(String logMessage) {
if (logMessage == null) return "";
String intermediate = CREDIT_CARD_PATTERN.matcher(logMessage).replaceAll("[CREDIT_CARD_MASKED]");
String finalMessage = SSN_PATTERN.matcher(intermediate).replaceAll("[SSN_MASKED]");
return finalMessage;
}
}
// Usage in your application
String rawLog = "User with SSN 123-45-6789 paid with card 4111-1111-1111-1111.";
logger.info(LogSanitizer.clean(rawLog));
// Logs: "User with SSN [SSN_MASKED] paid with card [CREDIT_CARD_MASKED]."
Java’s built-in object serialization is a powerful feature, but it can be a significant security risk. If your application deserializes data from an untrusted source—like a network request—an attacker could craft a special serialized object that executes arbitrary code when it’s deserialized.
My general advice is to avoid Java serialization for external communication. Use JSON or XML instead. If you absolutely must use it, you must put very strict filters in place to control what classes can be deserialized.
try (ObjectInputStream ois = new ObjectInputStream(untrustedInputStream)) {
// Create a filter that only allows specific classes from your application.
ObjectInputFilter strictFilter = ObjectInputFilter.Config.createFilter(
"maxdepth=3;maxarray=1000;com.yourcompany.yourapp.model.*;!*"
);
ois.setObjectInputFilter(strictFilter);
MyData data = (MyData) ois.readObject(); // This will throw if the stream doesn't match the filter.
}
For web applications, there’s a sneaky attack called Cross-Site Request Forgery, or CSRF. Here, a malicious website tricks a logged-in user’s browser into making an unwanted request to your application, like transferring funds or changing an email address. Because the browser sends your session cookies automatically, your application might think it’s a legitimate user request.
The defense is to include a secret, unpredictable token in your web forms. Your application generates this token and stores it with the user’s session. When the form is submitted, the token must be sent back and validated. A malicious site can’t know this token, so its forged request will fail.
Modern frameworks like Spring Security handle this automatically. You just need to enable it.
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Configuration
public class AppSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// CSRF protection is enabled by default. It adds a token to forms and validates it on POST, PUT, PATCH, DELETE.
.csrf().and()
.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin();
}
}
If you have a public API or login endpoint, it can be targeted by automated scripts that try to guess passwords or simply overwhelm your service. Rate limiting controls how many requests a single client can make in a given period. It’s a polite way to ensure one user doesn’t consume all your resources.
You can implement this using a token bucket algorithm. Think of a bucket that holds a certain number of tokens. Each request costs one token. Tokens refill slowly over time. If a client makes requests faster than tokens are added, their bucket runs dry and they have to wait.
// Example using the Bucket4j library
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.Refill;
public class RateLimiter {
private final Bucket bucket;
public RateLimiter() {
// Allow 100 requests, refilling 100 tokens every minute.
Bandwidth limit = Bandwidth.classic(100, Refill.intervally(100, Duration.ofMinutes(1)));
this.bucket = Bucket.builder().addLimit(limit).build();
}
public boolean allowRequest() {
// Try to consume 1 token.
return bucket.tryConsume(1);
}
}
// In your API controller
@RestController
public class ApiController {
private RateLimiter limiter = new RateLimiter();
@PostMapping("/api/action")
public ResponseEntity<String> performAction() {
if (!limiter.allowRequest()) {
return ResponseEntity.status(429).body("Too many requests. Please try again later.");
}
// Process the normal request...
return ResponseEntity.ok("Action performed.");
}
}
Finally, remember that your application’s security isn’t defined just by the code you write. It includes every library your project depends on. A vulnerability in a common open-source library can become a doorway into your system.
You must make it a routine to check for updates and known security issues in your dependencies. This can be automated. Tools can scan your project’s dependencies and compare them against databases of known vulnerabilities during your build process.
<!-- In your Maven pom.xml, you can add a plugin to check for vulnerabilities -->
<build>
<plugins>
<plugin>
<groupId>org.owasp</groupId>
<artifactId>dependency-check-maven</artifactId>
<version>7.1.1</version>
<executions>
<execution>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
Running mvn verify would then include a security check. If a critical vulnerability is found in one of your libraries, the build can fail, forcing you to address the issue before deployment. You should run these checks regularly, ideally as part of your continuous integration pipeline.
Security is not a destination where you arrive and stop. It’s a consistent practice. By integrating these techniques into your daily development work—validating input, using safe patterns for databases and passwords, managing secrets carefully, and auditing your tools—you build applications that are not just functional, but fundamentally more robust and trustworthy. Start with one or two of these practices, make them a habit, and then add more. Each layer you add makes it that much harder for an attack to succeed.