Lesson 42 • Advanced
Secure Coding Practices
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.
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);
}
}
}true <- alice_99
false <- ab
false <- robert'); DROP TABLE users;--
false <- <script>alert(1)</script>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.
}
}Connection and ausers table, so it has no fixed console output. The lesson is structural: the findUserBad method is exploitable andfindUserSafe is not. Always use the? placeholder version.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.
}
}Bad -> sh -c "cat report.txt; rm -rf /" (shell runs rm -rf /!)
Good -> ProcessBuilder runs: cat [report.txt; rm -rf /]
The ';' is part of the file name, not a command.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!)");
}
}SecureRandom. The token values are random, so they differ on every run and can't be shown as fixed output — run it ononecompiler.com/java to watch two unguessable 256-bit tokens print. Use this generator (not Math.random()) for anything an attacker must not predict.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.
readObject() on data from a network, upload, or cookie. Use a data format like JSON (Jackson) that only produces the plain types you ask for.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.
}
}? bound with setString.🧩 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
}
}onecompiler.com/java. You should see a ~32-character random token that changes every run, plus Length: 32.Common Errors (and the Fix)
- ❌ Building SQL by string concatenation:
"... WHERE name='" + name + "'"is injectable. Fix: use aPreparedStatementwith?andsetString(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: useSecureRandom.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 withmatches(...)— 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 fromSystem.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 |
❓ 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.
🎉 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.
Next up: JavaFX — building desktop GUI applications.
Sign up for free to track which lessons you've completed and get learning reminders.