Secure Coding Basics
Most real-world security incidents in application code trace back to a handful of avoidable habits — trusting untrusted data, mishandling secrets, and pulling in unvetted dependencies.
Secure coding is less about exotic attacks and more about consistently avoiding a known set of dangerous patterns: trusting data you don't control, running untrusted input as code or as a file path, letting resources be exhausted, and leaking secrets. None of this requires being a security specialist — it requires knowing where the common traps are and treating them as defaults to avoid, the same way you already avoid null-pointer bugs by habit.
Why This Matters for an Application Developer
Security is not a separate phase bolted on after functionality is done — the decisions that create vulnerabilities (trusting an input, choosing a deserialization strategy, building a file path from user data) are made in ordinary, everyday code. Most of the practices in this topic cost little or nothing to apply from the start, and cost far more to retrofit after an incident.
This topic covers foundational awareness suitable for any Java developer. It is not a substitute for a dedicated security review, threat modeling, or a library like OWASP's ESAPI for security-critical systems — treat it as a baseline, not a ceiling.
Avoid Deserializing Untrusted Data
Java's built-in object serialization (ObjectInputStream.readObject()) reconstructs live objects, including invoking constructors and methods, from a byte stream. If that byte stream comes from an untrusted source, a crafted stream can trigger unintended code execution through so-called "deserialization gadget chains" — a well-known and serious class of vulnerability in the Java ecosystem.
If you must deserialize with the built-in mechanism, use an allow-list (e.g. ObjectInputFilter, available since Java 9, restricting which classes may be deserialized) rather than trusting the stream unconditionally. Preferring a data-only format (JSON, protobuf) parsed into plain DTOs is the simpler and generally preferred approach for untrusted input.
// DANGEROUS if data comes from an untrusted network client:
// ObjectInputStream in = new ObjectInputStream(untrustedSocketStream);
// Object obj = in.readObject(); // can execute arbitrary gadget-chain logic
// SAFER: use a data format with no executable semantics
// e.g. JSON via a well-maintained library, parsed into known DTO types,
// with unknown fields ignored rather than reflectively instantiated.Injection Awareness
Injection vulnerabilities happen when untrusted data is concatenated into a command that is then interpreted (SQL, a shell command, an LDAP query) rather than treated purely as data. The fix pattern is consistent across injection types: never build the command string by concatenating raw input; use an API that keeps data and command syntax separate.
PreparedStatement sends the SQL text and the parameter value as separate channels to the database driver, so the parameter can never be reinterpreted as SQL syntax, no matter what characters it contains.
The same category applies to ProcessBuilder/Runtime.exec (never build a shell command string by concatenating user input; prefer passing arguments as a List<String> so the OS treats each element as a literal argument, not shell syntax) and to any templating or query language that mixes code and untrusted data.
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
public class SqlInjectionAwareness {
static ResultSet findUser(Connection conn, String username) throws java.sql.SQLException {
// VULNERABLE (do not do this):
// Statement stmt = conn.createStatement();
// stmt.executeQuery("SELECT * FROM users WHERE name = '" + username + "'");
// username = "' OR '1'='1" would return every row.
// SAFE: parameters are sent separately from the SQL text
PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM users WHERE name = ?");
ps.setString(1, username);
return ps.executeQuery();
}
}Path Traversal Awareness
Path traversal happens when untrusted input is used to build a file path, and the input contains sequences like ../ that escape the intended directory, allowing access to arbitrary files on the filesystem. Any code that opens a file whose name comes (even partially) from external input is at risk.
resolve() joins the untrusted name onto the trusted base directory, normalize() collapses any '..' segments, and the startsWith check confirms the final path is still inside baseDir before the file is touched -- rejecting an attempt like "../../etc/passwd".
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class PathTraversalAwareness {
static byte[] readUserFile(Path baseDir, String userSuppliedName) throws IOException {
Path resolved = baseDir.resolve(userSuppliedName).normalize();
if (!resolved.startsWith(baseDir)) {
throw new SecurityException("Path escapes allowed directory: " + userSuppliedName);
}
return Files.readAllBytes(resolved);
}
}Resource Exhaustion
Untrusted input can also be used to exhaust resources rather than corrupt data: an unbounded file upload, an unbounded request body read into memory, a regular expression vulnerable to catastrophic backtracking on crafted input ("ReDoS"), or an unbounded loop/recursion driven by attacker-controlled size fields, can all degrade or crash a service even without any traditional "exploit."
Defensive habits: cap the maximum size of anything read from an untrusted source before reading it (not after), set timeouts on network calls and regex matching where the library supports it, and validate size/count fields against a sane maximum before allocating structures sized by them.
import java.io.IOException;
import java.io.InputStream;
public class ResourceExhaustionAwareness {
static byte[] readBounded(InputStream in, int maxBytes) throws IOException {
return in.readNBytes(maxBytes + 1).length > maxBytes
? throwTooLarge()
: in.readAllBytes(); // simplified illustration; real code reads once
}
private static byte[] throwTooLarge() {
throw new IllegalStateException("input exceeds allowed size");
}
}Secret Handling
Passwords, API keys, and tokens should never be hardcoded into source code (they end up in version control history permanently, even if later removed) and should never be logged. Prefer environment variables, a dedicated secrets manager, or an external configuration mechanism kept out of the source tree, and treat any log statement that might include a request object, an exception's full context, or a config dump as a potential secret leak.
When comparing secrets (e.g. an API key or token supplied by a caller against a stored value), a naive String.equals short-circuits on the first mismatched character, which can (in principle) leak timing information about how much of the secret was guessed correctly. Security-sensitive comparisons should use a constant-time comparison (java.security.MessageDigest.isEqual performs a constant-time byte comparison) instead.
// AVOID:
// private static final String API_KEY = "sk-live-abc123..."; // hardcoded, in git history forever
// PREFER:
String apiKey = System.getenv("PAYMENT_API_KEY");
if (apiKey == null || apiKey.isBlank()) {
throw new IllegalStateException("PAYMENT_API_KEY environment variable is not set");
}Dependency Hygiene
Third-party libraries are part of your attack surface: a known vulnerability in a dependency is exploitable in your application even though you didn't write the vulnerable code. Basic hygiene: keep dependencies reasonably current, know what a dependency-vulnerability scanner (e.g. OWASP Dependency-Check, or your build tool's equivalent) reports about your project's dependency tree, and avoid pulling in dependencies you don't actually need just to save a few lines of code.
The widely-known Log4Shell vulnerability (a critical remote-code-execution flaw in a common logging library, disclosed in late 2021) is a canonical real-world example of why keeping dependencies patched matters even for code you never directly touch.
Least Privilege
Least privilege means a piece of code, a process, or a credential should have only the access it actually needs to do its job, and nothing more. In practice for a Java application: a database credential used by a reporting service should have read-only access if it never writes; a file-handling routine should be restricted to the specific directory it's meant to touch; an object that doesn't need to be mutable after construction should be made immutable so nothing downstream can accidentally (or maliciously, if it's third-party code) alter it.
This principle also applies at the API-design level: expose the narrowest interface that satisfies the caller's actual need, rather than handing out a broadly-capable object (e.g. a full mutable List reference) when a read-only view or a single value would do.
Practical Usage: A Layered Mindset
None of these practices is a silver bullet on its own; the effective approach layers them: validate and sanitize input at the boundary (see Input Validation), use APIs that keep data separate from executable syntax (prepared statements, ProcessBuilder argument lists), bound anything sized by untrusted input, keep secrets out of source and logs, and keep the dependency tree patched. Each layer catches what another layer might miss.
Edge Cases
A file path can look safe after simple string checks (no literal "../") yet still escape the intended directory through encoding tricks, symlinks, or platform-specific path separators — normalizing and resolving against a known base directory (as shown above) is more robust than string-pattern blacklisting.
Exceptions themselves can leak information: a stack trace or exception message returned directly to an external caller (e.g. in an HTTP error response) can reveal internal file paths, SQL structure, or library versions useful to an attacker — log full detail internally, but return a generic message externally.
Quick Reference
Deserialization : avoid on untrusted data; prefer JSON/DTOs or an ObjectInputFilter allow-list
Injection : use PreparedStatement / parameterized APIs, never string-concatenate untrusted data into a command
Path traversal : resolve().normalize() against a base dir, then verify startsWith(baseDir)
Resource limits : cap sizes/timeouts before reading untrusted input, not after
Secrets : env vars / secrets manager, never hardcoded or logged; constant-time comparison
Dependencies : keep patched, scan for known CVEs, avoid unnecessary libraries
Least privilege : narrowest access/interface that satisfies the actual needKey Takeaways
Avoid deserializing untrusted data with Java's built-in serialization; prefer data-only formats or a strict allow-list filter.
Injection (SQL, command, path) is prevented by keeping untrusted data separate from command/query syntax -- use parameterized APIs, never string concatenation.
Bound anything sized by untrusted input (file reads, request bodies, regex complexity) before processing it, to prevent resource exhaustion.
Never hardcode or log secrets; use environment variables or a secrets manager, and constant-time comparison for sensitive values.
Keep dependencies patched and minimal -- a vulnerability in a library is a vulnerability in your application, regardless of who wrote the code.
Apply least privilege at every level: credentials, file access, and the shape of the APIs your own code exposes.
String sql = "SELECT * FROM users WHERE name = '" + userName + "'"; statement.executeQuery(sql);
PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE name = ?"); ps.setString(1, userName); ps.executeQuery();
Concatenating untrusted input into SQL text allows SQL injection; a PreparedStatement sends the value through a separate channel that can never be reinterpreted as SQL syntax.
Path p = Paths.get(baseDir, userSuppliedFileName); Files.readAllBytes(p); // no check at all
Path p = baseDir.resolve(userSuppliedFileName).normalize(); if (!p.startsWith(baseDir)) throw new SecurityException("invalid path"); Files.readAllBytes(p);
Without normalizing and verifying the resolved path stays within baseDir, a userSuppliedFileName like "../../etc/passwd" can read files far outside the intended directory.
private static final String DB_PASSWORD = "hunter2";
String dbPassword = System.getenv("DB_PASSWORD");
A hardcoded secret is committed to version control permanently (even after later removal, it remains in history) and is visible to anyone with source access; environment variables or a secrets manager keep it out of the source tree.
if (suppliedToken.equals(storedToken)) { grantAccess(); } if (java.security.MessageDigest.isEqual(suppliedToken.getBytes(), storedToken.getBytes())) { grantAccess(); }
String.equals short-circuits on the first differing character, which can leak timing information about a secret; MessageDigest.isEqual performs a constant-time comparison for security-sensitive checks.
Why is Java's built-in object serialization considered dangerous when reading data from an untrusted source?
readObject() reconstructs live objects by invoking constructors and other code as part of reconstruction, so a maliciously crafted byte stream can trigger unintended code paths ("gadget chains") -- effectively letting the sender influence what code runs on the receiving JVM.
What is the core defense against SQL injection, and why does it work?
Using parameterized queries (PreparedStatement with placeholders and setX(...) calls) rather than string-concatenating input into SQL text; it works because the parameter value is transmitted to the database as pure data on a separate channel from the SQL syntax, so it can never be reinterpreted as part of the query structure.
Describe how you would defend a file-serving method against path traversal, given a base directory and a user-supplied file name.
Resolve the user-supplied name against the trusted base directory (baseDir.resolve(name)), normalize the result to collapse any '..' segments, and verify the normalized path still starts with the base directory before touching the filesystem -- rejecting the request otherwise.
Give two concrete ways untrusted input can cause resource exhaustion, beyond corrupting data.
An unbounded read of a request body or uploaded file into memory (no size cap), and a regular expression vulnerable to catastrophic backtracking (ReDoS) applied to a crafted string; both can degrade or crash a service purely through resource consumption, with no data corruption involved.
Why should secrets never be logged, even at a debug log level?
Log files are often stored, aggregated, and retained (sometimes indefinitely, sometimes shipped to third-party log services) well beyond the lifetime of the request that generated them, so a secret written to a log becomes a long-lived, widely-accessible copy of that secret outside the system meant to protect it.
Why does a known vulnerability in a third-party dependency matter even if you never call the vulnerable code path directly?
The vulnerable code still ships inside your application's runtime classpath; depending on how the library is used elsewhere in the dependency tree or how it's invoked reflectively/automatically (as with deserialization-triggered gadget chains), the vulnerable path can be reachable even without an explicit call from your own code.
What does 'least privilege' mean in the context of a database credential used by a read-only reporting service, and why does it matter?
The credential should be granted only SELECT access, not INSERT/UPDATE/DELETE/DDL rights it never uses; if that credential is ever leaked or the service is compromised, the blast radius is limited to what the credential can actually do, rather than granting an attacker full write access to the database.
Write a method that safely reads a user-requested file from within a fixed 'uploads' directory, rejecting any attempt to escape that directory.
import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; public class SafeFileReader { private static final Path UPLOADS_DIR = Path.of("/var/app/uploads").normalize(); static byte[] readUpload(String requestedName) throws IOException { Path resolved = UPLOADS_DIR.resolve(requestedName).normalize(); if (!resolved.startsWith(UPLOADS_DIR)) { throw new SecurityException("Rejected path traversal attempt: " + requestedName); } return Files.readAllBytes(resolved); } }
The method resolves the requested name against the fixed uploads directory, normalizes to collapse any '..' segments, and confirms the final absolute path is still inside UPLOADS_DIR before reading -- so a requestedName like "../../etc/passwd" is rejected with a SecurityException instead of leaking an arbitrary file.
What is the security problem with this code, and how should it be fixed?
userSuppliedDir is concatenated directly into a shell command string, so a value like "; rm -rf /" would execute an arbitrary second command through shell interpretation -- a command injection vulnerability. Fix: avoid invoking a shell at all when possible, and pass arguments as separate list elements so the OS treats them as literal data, e.g. 'new ProcessBuilder("ls", userSuppliedDir).start();' -- and additionally validate userSuppliedDir doesn't contain path traversal or unexpected characters.
Write a method that reads at most maxBytes from an InputStream and throws an exception if the stream contains more data than that, to prevent unbounded memory use from an untrusted source.
import java.io.IOException; import java.io.InputStream; public class BoundedReader { static byte[] readBounded(InputStream in, int maxBytes) throws IOException { byte[] data = in.readNBytes(maxBytes + 1); if (data.length > maxBytes) { throw new IllegalStateException("input exceeds allowed size of " + maxBytes + " bytes"); } return data; } } readNBytes(maxBytes + 1) reads at most one byte past the limit; if exactly maxBytes + 1 bytes came back, the source had more data than allowed, so the method rejects it before ever holding the full, potentially huge, payload in memory.
Identify the secret-handling problems with this code and describe a better approach.
The secret is hardcoded directly in source code, meaning it is committed to version control permanently (recoverable from history even after deletion), visible to anyone with repository access, and likely to be inadvertently logged or exposed if this class is ever printed/serialized. Better approach: load it at runtime from an environment variable or a dedicated secrets manager (e.g. 'String apiSecret = System.getenv("API_SECRET");'), never commit it to source control, and avoid logging it or including it in exception messages/toString() output.
ProcessBuilder pb = new ProcessBuilder("sh", "-c", "ls " + userSuppliedDir);
pb.start();public class Config {
public static final String API_SECRET = "sk_live_51H8xJ...";
}