Secure Coding
Reviewed & published by Brayan K
Security is a habit you build into every line, not a feature you bolt on later. By the end of this lesson you'll validate input, write injection-proof SQL, generate unguessable tokens, hash passwords correctly, and recognise the OWASP Top 10 risks in your own Java code.
Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Validate untrusted input with allow-lists
- Stop SQL injection with PreparedStatement
- Avoid command injection with ProcessBuilder
- Generate secrets with SecureRandom, not Math.random()
- Hash passwords with bcrypt and handle secrets safely
- Map real bugs to the OWASP Top 10 and avoid unsafe deserialization
Before You Start
You should be comfortable with JDBC (where SQL injection lives), REST APIs (where untrusted input arrives), and Exception Handling (so errors don't leak internals). A basic grasp of HTTP helps too.
🏰 A Real-World Analogy
Think of your application like a nightclub. Input validation is the bouncer who checks IDs at the door and only lets in people on the guest list — an allow-list. PreparedStatement is the rule that drinks orders are handed to the bartender on a printed ticket, so a customer can't shout an extra instruction and have it obeyed.
SecureRandom is the cloakroom ticket that's impossible to forge, while Math.random() is a ticket numbered 1, 2, 3 that anyone can guess. Hashing passwords is shredding the guest list so even a thief who grabs it can't read the names. And the OWASP Top 10 is the safety inspector's checklist of the ten ways clubs most often get robbed.
1️⃣ Validate Untrusted Input First
Every value that comes from outside your program — form fields, URL parameters, headers, uploaded files — is untrusted until you prove otherwise. The strongest approach is an allow-list (also called a whitelist): describe exactly what a valid value looks like and reject everything else. That's safer than a block-list, because you can't possibly list every bad input an attacker might invent.
In the example below, a username must match a strict regular expression. The two injection payloads are rejected before they can ever reach a database or a web page.
import java.util.regex.Pattern;
public class Main {
// Allow-list validation: say what IS allowed, reject everything else.
// A username may only contain letters, digits and underscores, 3-16 chars.
private static final Pattern USERNAME = Pattern.compile("^[A-Za-z0-9_]{3,16}$");
static boolean isValidUsername(String input) {
if (input == null) return false; // null is never valid
return USERNAME.matcher(input).matches(); // true only if the WHOLE string matches
}
public static void main(String[] args) {
String[] tries = {
"alice_99", // good
"ab", // too short
"robert'); DROP TABLE users;--", // SQL injection attempt
"<script>alert(1)</script>" // XSS attempt
};
for (String t : tries) {
// Validate at the boundary, BEFORE the value reaches a database or page.
System.out.println(isValidUsername(t) + " <- " + t);
}
}
}2️⃣ Stop SQL Injection with PreparedStatement
SQL injection happens when user input is glued into a query string and the database parses part of that input as SQL. It is OWASP A03: Injection and has caused some of the largest breaches in history. The fix is simple and absolute: never concatenate input into SQL. Use a PreparedStatement with ? placeholders, and bind each value with setString/setInt.
The placeholder sends the SQL and the data to the driver separately, so the value ' OR '1'='1 is searched for as a literal name instead of changing the query. Compare the vulnerable and fixed methods side by side.
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
public class Main {
// ❌ VULNERABLE: user input is concatenated straight into the SQL text.
static String findUserBad(Connection conn, String name) throws Exception {
String sql = "SELECT email FROM users WHERE name = '" + name + "'";
// If name = "' OR '1'='1", the WHERE clause is always true ->
// SELECT email FROM users WHERE name = '' OR '1'='1'
// The attacker dumps EVERY row. This is SQL injection (OWASP A03).
try (Statement st = conn.createStatement();
ResultSet rs = st.executeQuery(sql)) {
return rs.next() ? rs.getString("email") : null;
}
}
// ✅ FIXED: the ? placeholder keeps data and code separate.
static String findUserSafe(Connection conn, String name) throws Exception {
String sql = "SELECT email FROM users WHERE name = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, name); // the driver sends 'name' as DATA, never as SQL
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getString("email") : null;
}
}
// Now "' OR '1'='1" is looked up as a literal name and simply finds nothing.
}
}3️⃣ Avoid Command Injection
The same idea applies when you run external programs. If you build a shell command by concatenating input and hand it to Runtime.exec("sh -c ..."), the shell interprets metacharacters like ;, |, and & — so a filename of report.txt; rm -rf / runs a second, destructive command.
Use ProcessBuilder with each argument as its own list element and no shell. The OS receives the arguments as a list, so metacharacters are just literal characters in a filename — never commands.
import java.util.List;
public class Main {
public static void main(String[] args) throws Exception {
String filename = "report.txt; rm -rf /"; // attacker-controlled input
// ❌ VULNERABLE: building a shell command string.
// Runtime.getRuntime().exec("sh -c \"cat " + filename + "\"");
// The shell sees the ';' and runs "rm -rf /" as a SECOND command.
System.out.println("Bad -> sh -c \"cat " + filename + "\" (shell runs rm -rf /!)");
// ✅ FIXED: no shell, and each argument is a separate list element.
// ProcessBuilder never interprets ';' '|' '&' — filename is ONE argument.
ProcessBuilder pb = new ProcessBuilder(List.of("cat", filename));
System.out.println("Good -> ProcessBuilder runs: cat [" + filename + "]");
System.out.println(" The ';' is part of the file name, not a command.");
// pb.start(); // would just fail to find that oddly-named file - harmless.
}
}4️⃣ Secrets: Randomness, Hashing & Storage
Security tokens must be unpredictable. Math.random() and java.util.Random are ordinary pseudo-random generators: see a few outputs and you can predict the rest. For session IDs, reset links, and API keys use java.security.SecureRandom, which draws from the OS's cryptographic entropy.
Passwords are different again — you should never be able to recover them. Hash them with a slow, salted algorithm built for passwords: bcrypt (cost 12+), scrypt, or Argon2. Spring Security's BCryptPasswordEncoder adds a fresh random salt per password and verifies by re-hashing — you never decrypt.
❌ Never: plain text passwords, MD5/SHA-256 for passwords, Math.random() for tokens, secrets hardcoded in source.
✅ Always: bcrypt (cost ≥ 12), SecureRandom tokens, secrets from env vars or a secret manager.
import java.security.SecureRandom;
import java.util.Base64;
public class Main {
// ❌ Math.random() is a predictable PRNG - fine for dice, NEVER for security.
// ✅ SecureRandom is a cryptographically strong source for tokens, salts, keys.
private static final SecureRandom RNG = new SecureRandom();
static String newToken() {
byte[] bytes = new byte[32]; // 32 bytes = 256 bits of entropy
RNG.nextBytes(bytes); // fill with unpredictable bytes
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
public static void main(String[] args) {
// Each token is unguessable - use for session IDs, password-reset links, API keys.
System.out.println("Session token: " + newToken());
System.out.println("Reset token: " + newToken());
// Math.random() returns a double in [0,1) - an attacker who sees a few
// outputs can predict the rest. Don't build secrets on it:
System.out.println("Math.random() -> " + Math.random() + " (predictable - avoid!)");
}
}5️⃣ Safe Deserialization & TLS Basics
Java's built-in serialization is dangerous on untrusted data. Calling new ObjectInputStream(stream).readObject() rebuilds arbitrary objects and runs their readObject methods, so a crafted byte stream can trigger a "gadget chain" and achieve remote code execution — before you ever look at the result. This is OWASP A08 / CWE-502.
For data in transit, use TLS everywhere (HTTPS), keep your JDK and crypto libraries current so you're on modern TLS 1.2/1.3 cipher suites, and add the Strict-Transport-Security (HSTS) header so browsers refuse to downgrade to plain HTTP. Use vetted libraries for cryptography — never invent your own algorithm.
6️⃣ OWASP Top 10 → Java Defences
The OWASP Top 10 is the industry's standard list of the most critical web application risks. Here's how the risks in this lesson map to concrete Java defences:
| OWASP Risk | Attack | Java Defence |
|---|---|---|
| A01 Broken Access Control | Acting beyond your role | Server-side authorization checks |
| A02 Cryptographic Failures | Weak/plain-text secrets | bcrypt, TLS, SecureRandom |
| A03 Injection | SQL / command injection | PreparedStatement, ProcessBuilder |
| A05 Security Misconfiguration | Defaults, verbose errors | Harden config, generic error pages |
| A07 Auth Failures | Weak passwords, guessable tokens | Spring Security + SecureRandom |
| A08 Software/Data Integrity | Untrusted deserialization | Avoid Java serialization; use JSON |
🎯 Your Turn #1 — Validate a PIN
Finish the allow-list regex and the match check so only an exact 6-digit PIN is accepted. Fill in the blanks, then run it.
import java.util.regex.Pattern;
public class Main {
public static void main(String[] args) {
// 🎯 YOUR TURN — fill in the blanks marked with ___
// 1) Validate a 6-digit PIN. Build an allow-list regex:
// start ^, then a digit class, repeated exactly 6 times, then end $.
Pattern pin = Pattern.compile(___); // 👉 e.g. "^[0-9]{6}$"
// 2) A safe check returns true only if the WHOLE string matches.
String input = "1234"; // too short on purpose
boolean ok = pin.matcher(input).___; // 👉 replace ___ with matches()
System.out.println("PIN valid? " + ok);
// ✅ Expected output: PIN valid? false
// (Try input = "123456" and you should get: PIN valid? true)
}
}🎯 Your Turn #2 — Parameterize the Query
Turn this login lookup into an injection-proof query: add ? placeholders and bind each value by position.
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
public class Main {
// 🎯 YOUR TURN — fill in the blanks marked with ___
// Turn this lookup into an injection-proof query.
static boolean login(Connection conn, String user, String pwHash) throws Exception {
// 1) Use a ? placeholder for EACH user-supplied value - never concatenation.
String sql = "SELECT 1 FROM users WHERE name = ___ AND pw_hash = ___"; // 👉 use ? and ?
try (PreparedStatement ps = conn.prepareStatement(sql)) {
// 2) Bind the values by position (1-based). user goes to the first ?.
ps.setString(1, ___); // 👉 bind 'user'
ps.setString(2, ___); // 👉 bind 'pwHash'
try (ResultSet rs = ps.executeQuery()) {
return rs.next(); // a row exists => credentials matched
}
}
// ✅ Expected: input like "admin' --" is searched for literally and finds nothing.
}
}🧩 Mini-Challenge — Reset-Token Generator
No blanks this time — just a brief and a comment outline. Build a secure, URL-safe password-reset token from scratch using SecureRandom.
import java.security.SecureRandom;
import java.util.Base64;
public class Main {
public static void main(String[] args) {
// 🎯 MINI-CHALLENGE: a secure password-reset token generator
// 1. Create ONE SecureRandom instance (reuse it - don't make a new one each call).
// 2. Make a byte[] of length 24 and fill it with rng.nextBytes(...).
// 3. Encode it with Base64.getUrlEncoder().withoutPadding() so it's URL-safe.
// 4. Print the token, then print its length.
//
// ✅ Expected: a random ~32-char string that changes every run,
// e.g. "Token: 8Kf2..." then "Length: 32"
// (24 bytes Base64-encode to 32 characters.)
// your code here
}
}Common Errors (and the Fix)
- ❌ Building SQL by string concatenation: "... WHERE name='" + name + "'" is injectable. Fix: use a PreparedStatement with ? and setString(1, name) — never glue input into the query.
- ❌ Using Math.random() for tokens: the output is predictable, so session IDs and reset links can be guessed. Fix: use SecureRandom.nextBytes(...) and Base64-encode the bytes.
- ❌ Storing plaintext (or MD5/SHA) passwords: one database leak exposes every account. Fix: hash with BCryptPasswordEncoder(12) and verify with matches(...) — you never store or decrypt the original.
- ❌ Deserializing untrusted data: new ObjectInputStream(stream).readObject() on a request body can run attacker code (CWE-502). Fix: parse JSON with Jackson into known types instead of Java serialization.
- ❌ Hardcoding secrets: String pw = "admin123"; ends up committed to git. Fix: read from System.getenv("DB_PASSWORD") or a secret manager.
Five Rules You Never Break
- ⚠️ Never concatenate user input into SQL or shell commands — parameterise instead.
- ⚠️ Never store passwords in plain text or fast hashes — use bcrypt (cost ≥ 12).
- ⚠️ Never trust client input — validate on the server, even if the client already did.
- ⚠️ Never log secrets — passwords, tokens, card numbers, session IDs.
- ⚠️ Never deserialize untrusted data with ObjectInputStream.
📋 Quick Reference
| Threat | Defence | Tool / API |
|---|---|---|
| Untrusted input | Pattern.matches(allowList) | java.util.regex |
| SQL injection | ps.setString(1, v) | PreparedStatement |
| Command injection | new ProcessBuilder(List.of(...)) | ProcessBuilder (no shell) |
| Weak randomness | rng.nextBytes(b) | SecureRandom |
| Password storage | encoder.encode(pw) | BCryptPasswordEncoder(12) |
| Unsafe deserialization | objectMapper.readValue(...) | Jackson JSON (not readObject) |
| Secrets / CVEs | System.getenv(...) / dep scan | Vault, OWASP Dependency-Check |
🎉 Lesson Complete!
You can now treat every external value as untrusted, validate it with an allow-list, kill SQL injection with PreparedStatement, avoid command injection with ProcessBuilder, generate unguessable tokens with SecureRandom, hash passwords with bcrypt, refuse to deserialize untrusted data, keep secrets out of source — and map each risk to the OWASP Top 10.
Practice quiz
What is the strongest approach to validating untrusted input?
- An allow-list that describes exactly what is valid and rejects everything else
- A block-list that bans known bad characters
- Trusting input the client already validated
- Logging the input and continuing
Answer: An allow-list that describes exactly what is valid and rejects everything else. An allow-list (whitelist) is safer than a block-list because you cannot possibly list every malicious input an attacker might invent.
Why does a PreparedStatement stop SQL injection while string concatenation does not?
- It encrypts the SQL before sending it
- It sends the SQL and the data separately, so values are never parsed as SQL
- It escapes the query with HTML encoding
- It runs the query in a sandbox
Answer: It sends the SQL and the data separately, so values are never parsed as SQL. The ? placeholder keeps data and code separate: the driver sends the values as DATA, so input like ' OR '1'='1 is treated as a literal name.
To avoid command injection, you should run external programs with...
- Runtime.exec("sh -c ...") built by concatenating input
- A shell so metacharacters work
- ProcessBuilder with each argument as its own list element and no shell
- eval() on the command string
Answer: ProcessBuilder with each argument as its own list element and no shell. ProcessBuilder with a List of arguments and no shell means ';' '|' '&' are literal characters in a filename, never separate commands.
Which class should you use to generate session IDs and password-reset tokens?
- Math.random()
- java.util.Random
- java.security.SecureRandom
- System.currentTimeMillis()
Answer: java.security.SecureRandom. SecureRandom draws from the OS's cryptographic entropy and is unpredictable. Math.random() and java.util.Random are ordinary PRNGs an attacker can predict.
How should passwords be stored?
- In plain text so you can email them back
- Hashed with a slow, salted algorithm like bcrypt (cost 12+)
- Encrypted with a fast algorithm like MD5
- Hashed with SHA-256 and no salt
Answer: Hashed with a slow, salted algorithm like bcrypt (cost 12+). Use a deliberately slow, salted password hash (bcrypt, scrypt, or Argon2). MD5 and SHA-256 are too fast and let attackers try billions of guesses per second.
Why is calling readObject() on untrusted data dangerous?
- It is slow
- It only works on local files
- It rebuilds arbitrary objects and can trigger a gadget chain leading to remote code execution
- It always returns null
Answer: It rebuilds arbitrary objects and can trigger a gadget chain leading to remote code execution. ObjectInputStream.readObject() runs the objects' readObject methods, so a crafted byte stream can execute code (OWASP A08 / CWE-502). Use JSON instead.
What does the username allow-list regex "^[A-Za-z0-9_]{3,16}$" require?
- Any string containing at least one letter
- 3 to 16 characters that are only letters, digits, or underscores, matching the whole string
- Exactly 16 characters of any kind
- A string that starts with a digit
Answer: 3 to 16 characters that are only letters, digits, or underscores, matching the whole string. The anchors ^ and $ force the WHOLE string to match: 3-16 chars from [A-Za-z0-9_]. Payloads like <script>...</script> are rejected.
Which OWASP Top 10 category covers SQL and command injection?
- A01 Broken Access Control
- A02 Cryptographic Failures
- A03 Injection
- A08 Software/Data Integrity Failures
Answer: A03 Injection. Injection (SQL, command, etc.) is OWASP A03, defended with PreparedStatement and ProcessBuilder.
How should you store a database password or API key?
- Hardcoded in source and committed to git
- In a comment so it is easy to find
- Read at runtime from environment variables or a secret manager
- In a public README
Answer: Read at runtime from environment variables or a secret manager. Read secrets from System.getenv or a secret manager (Vault, AWS Secrets Manager). Hardcoding leaks every secret when the repo leaks.
Is input validation alone enough to stop SQL injection and XSS?
- Yes, validation makes input completely safe
- No - pair it with PreparedStatement parameters for SQL and context-aware output encoding for HTML
- Yes, if the regex is strict enough
- No, you must encrypt all input instead
Answer: No - pair it with PreparedStatement parameters for SQL and context-aware output encoding for HTML. Validation is the first filter, not the last defence. The parameterised query and the output encoder actually keep data and code separate.
Continue this course
- Previous: Logging & Monitoring with Log4j2 & SLF4J
- Next: JavaFX for Desktop UI Development — Build desktop GUI applications with JavaFX scenes, controls, and CSS
- Quick reference: Java cheat sheet
Frequently asked questions
Is input validation enough to stop SQL injection and XSS?
No — validation is your first filter, not your last defence. Always pair it with the right output-time control: PreparedStatement parameters for SQL, and context-aware output encoding (OWASP Java Encoder) for HTML. Validation reduces the attack surface, but it is the parameterised query and the encoder that actually make the input safe, because they keep data and code separate no matter what slipped through.
Why is PreparedStatement safe when string concatenation is not?
A concatenated query sends the SQL and the user input as one finished string, so input like ' OR '1'='1 changes what the query means. A PreparedStatement sends the SQL with ? placeholders first, then the values separately, so the database always treats those values as data and never parses them as SQL. Use a placeholder for every user-supplied value and never build query text by gluing strings together.
Can I hash passwords with SHA-256 or MD5 if I add a salt?
No. MD5 and SHA-256 are designed to be fast, which means an attacker with your hashes can try billions of guesses per second even with a salt. Use a deliberately slow, salted algorithm built for passwords: bcrypt (cost 12 or higher), scrypt, or Argon2. Spring Security's BCryptPasswordEncoder generates a fresh random salt per password and embeds the cost and salt in the hash string, so you store and compare one value.
When must I use SecureRandom instead of Math.random()?
Use SecureRandom for anything an attacker must not be able to predict: session IDs, password-reset tokens, API keys, salts, CSRF tokens, and cryptographic keys. Math.random() and java.util.Random are ordinary PRNGs — observing a few outputs lets an attacker predict the rest, which is fine for a game but catastrophic for a token. Math.random() is only acceptable for non-security randomness like shuffling a deck or picking a sample.
Why is Java deserialization of untrusted data dangerous?
ObjectInputStream.readObject() rebuilds arbitrary objects and runs their readObject/readResolve methods, so a crafted byte stream can trigger 'gadget chains' that execute code — remote code execution — before you ever inspect the result (OWASP A08, CWE-502). Never call readObject() on data from a network, file upload, cookie, or any source you do not fully control. Use a data format like JSON (Jackson) that only produces the plain types you ask for.
How should I store secrets like database passwords and API keys?
Never hardcode them in source or commit them to git — a leaked repo leaks every secret. Read them at runtime from environment variables (System.getenv) or, better, a secret manager such as HashiCorp Vault, AWS Secrets Manager, or your platform's secret store. Keep secrets out of logs and error messages, scope each one to least privilege, and rotate them if they are ever exposed.