Java · lesson 46 of 66
Beginner
17- 1 Introduction to Java
- 2 Setting Up Java
- 3 Data Types in Java
- 4 Operators in Java
- 5 Variables and Operators in Java
- 6 Control Flow in Java
- 7 Arrays in Java
- 8 Functions (Methods) in Java
- 9 Strings in Java
- 10 Methods in Java
- 11 Java OOP — Complete Guide
- 12 Introduction to Object-Oriented Programming in Java
- 13 Encapsulation in Java
- 14 Inheritance in Java
- 15 Polymorphism in Java
- 17 Abstraction in Java
- 18 Error Types in Java
Intermediate
38- 16 Regular Expressions in Java
- 19 Interfaces in Java — A Deep Dive
- 20 Abstract Classes in Java
- 21 Method Overloading in Java
- 22 Composition vs Inheritance in Java
- 23 Generics in Java
- 27 Exception Handling in Java
- 28 Java Streams API
- 29 Collections Framework in Java
- 30 Optional in Java
- 31 File Handling in Java
- 32 Records in Java
- 33 Java 8 Features
- 34 Sealed Classes and Interfaces in Java
- 35 Multithreading in Java
- 37 JDBC — Database Access in Java
- 39 Maven — Java Build and Dependency Management
- 42 Gradle — Modern Java Build Tool
- 44 Unit Testing with JUnit 5 and Mockito
- 45 Logging in Java with SLF4J and Logback
- 49 Spring Boot — Introduction
- 50 Dependency Injection in Spring Boot
- 51 Java Project Ideas
- 52 Building REST APIs with Spring Boot
- 53 Validation in Spring Boot
- 54 Spring Data JPA — Database Access
- 55 Exception Handling in Spring Boot
- 56 Spring Security — Authentication and Authorization
- 57 JWT Authentication in Spring Boot
- 58 Dockerizing Spring Boot Applications
- 59 Java Enums: Beyond Simple Constants
- 60 Inner and Anonymous Classes in Java
- 61 Java Generics: Bounded Types, Wildcards, and Generic Methods
- 62 Java Date/Time API: LocalDate, LocalDateTime, and Duration
- 63 Java NIO.2: Paths, Files, and WatchService
- 64 Java Annotations: Custom Types and Meta-Information
- 65 Java Reflection: Dynamic Type Inspection and Method Invocation
- 66 Java Localization: ResourceBundle and Locale Support
Advanced
11Also part of: Cryptography Web Security (OWASP Top 10, fix-side)
Java Security
Secure Java coding by running it: allowlist validation, SQL injection vs PreparedStatement, PBKDF2 hashing, SecureRandom, AES-GCM and KeyStore.
By SkillByExample · Updated
Most Java vulnerabilities are not exotic: input that changes a query’s meaning, passwords hashed with the wrong function, guessable tokens, homemade crypto, and file paths that escape their directory. Each section runs the safe version next to the unsafe one. Every example is a single file run with the JDK 21 source launcher; the SQL example adds the H2 in-memory database jar to the class path.
Allowlist validation
import java.util.regex.Pattern;
public class InputValidatorDemo {
static final Pattern USERNAME = Pattern.compile("[a-zA-Z0-9_-]{3,30}");
static String requireUsername(String input) {
if (input == null || !USERNAME.matcher(input).matches())
throw new IllegalArgumentException("Invalid username");
return input;
}
static int requireInRange(int value, int min, int max, String field) {
if (value < min || value > max)
throw new IllegalArgumentException(field + " must be between " + min + " and " + max);
return value;
}
public static void main(String[] args) {
for (String u : new String[] {"alice_92", "alice'; DROP TABLE users;--", "ab", "ālice"}) {
try {
System.out.println("ok: " + requireUsername(u));
} catch (IllegalArgumentException e) {
System.out.println("rejected: " + u);
}
}
try { requireInRange(-5, 1, 100, "quantity"); }
catch (IllegalArgumentException e) { System.out.println(e.getMessage()); }
}
}
$ java InputValidatorDemo.java
ok: alice_92
rejected: alice'; DROP TABLE users;--
rejected: ab
rejected: ālice
quantity must be between 1 and 100
The pattern describes what a valid username is — an allowlist — so the injection attempt, the too-short name, and the non-ASCII lookalike are all rejected without anyone having listed them in advance. A denylist of “dangerous characters” has to anticipate every attack; an allowlist doesn’t. Validation reduces risk but is not the SQL defence — that is the next example.
SQL injection versus PreparedStatement
import java.sql.*;
public class SqlInjection {
public static void main(String[] args) throws SQLException {
try (Connection c = DriverManager.getConnection("jdbc:h2:mem:shop")) {
c.createStatement().execute("""
CREATE TABLE users(name VARCHAR(20), secret VARCHAR(20));
INSERT INTO users VALUES ('alice', 'a-secret'), ('bob', 'b-secret')""");
String input = "nobody' OR '1'='1";
String sql = "SELECT name, secret FROM users WHERE name = '" + input + "'";
System.out.println("concatenated: " + sql);
try (ResultSet rs = c.createStatement().executeQuery(sql)) {
while (rs.next()) System.out.println(" leaked " + rs.getString(1) + " " + rs.getString(2));
}
try (PreparedStatement ps = c.prepareStatement("SELECT name, secret FROM users WHERE name = ?")) {
ps.setString(1, input);
try (ResultSet rs = ps.executeQuery()) {
System.out.println("prepared: rows found = " + (rs.next() ? "some" : "none"));
}
}
}
}
}
$ java -cp h2-2.2.224.jar SqlInjection.java
concatenated: SELECT name, secret FROM users WHERE name = 'nobody' OR '1'='1'
leaked alice a-secret
leaked bob b-secret
prepared: rows found = none
Concatenation let the input close the string literal and append OR '1'='1', which is true for
every row — so the query returned everyone’s secret. With ? and setString, the driver sends the
input as a value, never as SQL, so it is just an odd name that matches nobody. Dynamic parts that
cannot be parameters (column names, ORDER BY direction) must be chosen from a fixed allowlist.
Password hashing with PBKDF2
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
public class Pbkdf2Demo {
static final int ITERATIONS = 600_000; // OWASP guidance for PBKDF2-HMAC-SHA256
static byte[] hash(char[] password, byte[] salt) throws Exception {
var spec = new PBEKeySpec(password, salt, ITERATIONS, 256);
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded();
}
public static void main(String[] args) throws Exception {
byte[] salt = new byte[16];
new SecureRandom().nextBytes(salt);
byte[] stored = hash("correct horse".toCharArray(), salt);
System.out.println("stored record: pbkdf2-sha256:" + ITERATIONS + ":<salt>:<hash>, hash bytes = " + stored.length);
System.out.println("right password: " + MessageDigest.isEqual(stored, hash("correct horse".toCharArray(), salt)));
System.out.println("wrong password: " + MessageDigest.isEqual(stored, hash("correct horsf".toCharArray(), salt)));
byte[] otherSalt = new byte[16];
new SecureRandom().nextBytes(otherSalt);
System.out.println("same password, new salt, same hash? " + MessageDigest.isEqual(stored, hash("correct horse".toCharArray(), otherSalt)));
System.out.println(Base64.getEncoder().encodeToString(salt).length() + " chars of Base64 salt to store");
}
}
$ java Pbkdf2Demo.java
stored record: pbkdf2-sha256:600000:<salt>:<hash>, hash bytes = 32
right password: true
wrong password: false
same password, new salt, same hash? false
24 chars of Base64 salt to store
The salt is random per user, so two users with the same password get different hashes and a
precomputed table is useless. The 600,000 iterations make each guess deliberately expensive.
Verification recomputes with the stored salt and compares with MessageDigest.isEqual, which takes
the same time whether the first or the last byte differs, leaking nothing through timing.
SecureRandom tokens, not Random
import java.security.SecureRandom;
import java.util.Base64;
import java.util.HashSet;
import java.util.Random;
import java.util.Set;
public class TokenDemo {
static final SecureRandom RNG = new SecureRandom();
static String token(int bytes) {
byte[] b = new byte[bytes];
RNG.nextBytes(b);
return Base64.getUrlEncoder().withoutPadding().encodeToString(b);
}
public static void main(String[] args) {
Set<String> tokens = new HashSet<>();
for (int i = 0; i < 10_000; i++) tokens.add(token(32));
System.out.println("10,000 tokens, distinct: " + tokens.size() + ", length: " + token(32).length());
System.out.println("url-safe: " + tokens.stream().allMatch(t -> t.matches("[A-Za-z0-9_-]+")));
Random a = new Random(42), b = new Random(42);
System.out.println("java.util.Random with the same seed repeats: " + (a.nextLong() == b.nextLong()));
System.out.printf("6-digit OTP: %d digits%n", String.format("%06d", RNG.nextInt(1_000_000)).length());
}
}
$ java TokenDemo.java
10,000 tokens, distinct: 10000, length: 43
url-safe: true
java.util.Random with the same seed repeats: true
6-digit OTP: 6 digits
32 random bytes encode to 43 URL-safe characters with no padding — 256 bits that cannot be guessed.
The last lines show why java.util.Random is unfit: its whole future is fixed by a 48-bit seed, and
a few observed outputs are enough to recover it. nextInt(bound) also avoids the modulo bias of
Math.abs(nextLong()) % max.
Authenticated encryption with AES-GCM
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Arrays;
import javax.crypto.*;
import javax.crypto.spec.GCMParameterSpec;
public class AesGcmDemo {
static final SecureRandom RNG = new SecureRandom();
static byte[] encrypt(byte[] plain, SecretKey key) throws Exception {
byte[] iv = new byte[12];
RNG.nextBytes(iv); // fresh nonce every time
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv));
byte[] ct = c.doFinal(plain);
byte[] out = Arrays.copyOf(iv, 12 + ct.length); // store IV + ciphertext
System.arraycopy(ct, 0, out, 12, ct.length);
return out;
}
static byte[] decrypt(byte[] in, SecretKey key) throws Exception {
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, in, 0, 12));
return c.doFinal(in, 12, in.length - 12);
}
public static void main(String[] args) throws Exception {
KeyGenerator gen = KeyGenerator.getInstance("AES");
gen.init(256);
SecretKey key = gen.generateKey();
byte[] msg = "Attack at dawn".getBytes(StandardCharsets.UTF_8);
byte[] e1 = encrypt(msg, key), e2 = encrypt(msg, key);
System.out.println("sizes: plain " + msg.length + ", sealed " + e1.length + " (12 IV + 14 + 16 tag)");
System.out.println("same message, same ciphertext? " + Arrays.equals(e1, e2));
System.out.println("decrypted: " + new String(decrypt(e1, key), StandardCharsets.UTF_8));
e1[20] ^= 1; // flip one bit
try { decrypt(e1, key); }
catch (AEADBadTagException ex) { System.out.println("tampered: " + ex.getMessage()); }
}
}
$ java AesGcmDemo.java
sizes: plain 14, sealed 42 (12 IV + 14 + 16 tag)
same message, same ciphertext? false
decrypted: Attack at dawn
tampered: Tag mismatch
A random 12-byte IV per message means encrypting the same text twice gives different ciphertexts,
so an observer cannot tell repeated messages apart. The 16-byte tag authenticates the data: flipping
a single bit makes doFinal throw instead of returning corrupted plaintext. Never reuse an IV with
the same key — with GCM that leaks the keystream and lets an attacker forge messages.
Keeping keys in a KeyStore
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.security.KeyStore;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
public class KeyStoreDemo {
public static void main(String[] args) throws Exception {
char[] storePass = "changeit-store".toCharArray();
KeyGenerator gen = KeyGenerator.getInstance("AES");
gen.init(256);
SecretKey key = gen.generateKey();
KeyStore ks = KeyStore.getInstance("PKCS12");
ks.load(null, null); // new, empty store
ks.setEntry("orders-key", new KeyStore.SecretKeyEntry(key), new KeyStore.PasswordProtection(storePass));
ByteArrayOutputStream file = new ByteArrayOutputStream(); // stands in for a .p12 file
ks.store(file, storePass);
KeyStore loaded = KeyStore.getInstance("PKCS12");
loaded.load(new ByteArrayInputStream(file.toByteArray()), storePass);
SecretKey back = (SecretKey) loaded.getKey("orders-key", storePass);
System.out.println("aliases: " + java.util.Collections.list(loaded.aliases()));
System.out.println(back.getAlgorithm() + " key restored intact: " + java.util.Arrays.equals(key.getEncoded(), back.getEncoded()));
try {
KeyStore.getInstance("PKCS12").load(new ByteArrayInputStream(file.toByteArray()), "guess".toCharArray());
} catch (java.io.IOException e) {
System.out.println("wrong password: " + e.getMessage());
}
}
}
$ java KeyStoreDemo.java
aliases: [orders-key]
AES key restored intact: true
wrong password: Integrity check failed: java.security.UnrecoverableKeyException: Failed PKCS12 integrity checking
A PKCS12 keystore keeps keys encrypted under a password, so they do not sit in source code or
config files as plain strings. The same KeyStore type holds the private keys and certificates
that a KeyManagerFactory/TrustManagerFactory turns into an SSLContext for TLS — the JDK’s
HttpClient already verifies server certificates against the default trust store, so the main TLS
rule is: never install a trust-all TrustManager to make an error go away.
Path traversal
import java.io.IOException;
import java.nio.file.*;
public class PathTraversalDemo {
static String readUserFile(Path base, String filename) throws IOException {
Path resolved = base.resolve(filename).normalize();
if (!resolved.startsWith(base))
throw new SecurityException("Path traversal attempt: " + filename);
return Files.readString(resolved);
}
public static void main(String[] args) throws IOException {
Path base = Files.createTempDirectory("user-files").toRealPath();
Files.writeString(base.resolve("notes.txt"), "hello from a legitimate file");
for (String name : new String[] {"notes.txt", "../../../../etc/passwd", "/etc/passwd", "sub/../notes.txt"}) {
try {
System.out.println("ok: " + name + " -> " + readUserFile(base, name));
} catch (SecurityException e) {
System.out.println("blocked: " + e.getMessage());
}
}
}
}
$ java PathTraversalDemo.java
ok: notes.txt -> hello from a legitimate file
blocked: Path traversal attempt: ../../../../etc/passwd
blocked: Path traversal attempt: /etc/passwd
ok: sub/../notes.txt -> hello from a legitimate file
normalize() collapses the .. segments, and resolve with an absolute argument simply returns
that argument — so both attacks produce a path outside base, which startsWith catches.
sub/../notes.txt normalises back inside and is allowed. Checking the raw string for ".." would
miss the absolute-path case entirely.
Deserialization filters
import java.io.*;
import java.util.ArrayList;
import java.util.List;
public class DeserializationDemo {
static class Gadget implements Serializable {}
static byte[] serialize(Object o) throws IOException {
var bytes = new ByteArrayOutputStream();
try (var out = new ObjectOutputStream(bytes)) { out.writeObject(o); }
return bytes.toByteArray();
}
static Object read(byte[] data) throws Exception {
try (var in = new ObjectInputStream(new ByteArrayInputStream(data))) {
in.setObjectInputFilter(ObjectInputFilter.Config.createFilter("java.util.ArrayList;java.lang.Object;java.lang.String;!*"));
return in.readObject();
}
}
public static void main(String[] args) throws Exception {
System.out.println("allowed: " + read(serialize(new ArrayList<>(List.of("a", "b")))));
try {
read(serialize(new Gadget()));
} catch (InvalidClassException e) {
System.out.println("rejected: " + e.getMessage());
}
}
}
$ java DeserializationDemo.java
allowed: [a, b]
rejected: filter status: REJECTED
ObjectInputStream will instantiate any serializable class a byte stream names, and attackers
chain such “gadget” classes into remote code execution. An ObjectInputFilter (Java 9+) checks each
class before it is created and rejects anything not listed, via the final !*. java.lang.Object
is in the list because ArrayList serializes its contents as an Object[], and the filter checks an
array by its element type — leave it out and even the harmless list is rejected. Better still, accept JSON or Protocol Buffers from untrusted sources instead of Java serialization.
OWASP Top 10 mapped to Java
| # | Risk | Java mitigation |
|---|---|---|
| A01 | Broken access control | Server-side checks on every request, e.g. Spring Security @PreAuthorize |
| A02 | Cryptographic failures | AES-GCM, PBKDF2/BCrypt/Argon2, TLS; no MD5/SHA-1 for passwords |
| A03 | Injection | PreparedStatement, allowlists for identifiers, no shell string building |
| A05 | Security misconfiguration | No debug endpoints or default credentials in production |
| A06 | Vulnerable components | Dependabot or mvn versions:display-dependency-updates |
| A07 | Authentication failures | SecureRandom tokens, short sessions, rate limiting |
| A08 | Integrity failures | Deserialization filters, verified dependencies |
| A10 | SSRF | Allowlist outbound hosts before calling HttpClient |
Practice
- Drop the PBKDF2 iteration count from 600,000 to 1,000 and time one
hashcall at each setting withSystem.nanoTime(). Why is a fast hash the wrong choice for passwords?
Solution
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
public class Pbkdf2Timing {
static long millis(int iterations) throws Exception {
var f = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
long start = System.nanoTime();
f.generateSecret(new PBEKeySpec("pw".toCharArray(), new byte[16], iterations, 256));
return (System.nanoTime() - start) / 1_000_000;
}
public static void main(String[] args) throws Exception {
millis(1_000); // warm up
long slow = millis(600_000), fast = millis(1_000);
System.out.println("600k iterations slower than 1k: " + (slow > fast * 50));
}
}
$ java Pbkdf2Timing.java
600k iterations slower than 1k: true
The cost grows linearly with the iteration count, and it is paid per guess. A login pays it once; an attacker with a stolen database pays it billions of times, so fast hashing helps only the attacker. (The exact milliseconds vary by machine, so the program prints the comparison instead.)
- In
AesGcmDemo, flip a bit in the IV (e1[0] ^= 1) instead of the ciphertext. Does decryption still fail?
Solution
import java.security.SecureRandom;
import javax.crypto.*;
import javax.crypto.spec.GCMParameterSpec;
public class TamperIv {
public static void main(String[] args) throws Exception {
KeyGenerator gen = KeyGenerator.getInstance("AES");
gen.init(256);
SecretKey key = gen.generateKey();
byte[] iv = new byte[12];
new SecureRandom().nextBytes(iv);
Cipher enc = Cipher.getInstance("AES/GCM/NoPadding");
enc.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv));
byte[] ct = enc.doFinal("Attack at dawn".getBytes());
iv[0] ^= 1;
Cipher dec = Cipher.getInstance("AES/GCM/NoPadding");
dec.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, iv));
try { dec.doFinal(ct); }
catch (AEADBadTagException e) { System.out.println("IV tampering detected: " + e.getMessage()); }
}
}
$ java TamperIv.java
IV tampering detected: Tag mismatch
The tag is computed with a key stream derived from the IV, so a changed IV fails verification just like a changed ciphertext.
- Add a
requirePositiveAmount(BigDecimal)validator that rejectsnull, zero and negatives, and show why money should not be adouble.
Solution
import java.math.BigDecimal;
public class AmountValidator {
static BigDecimal requirePositiveAmount(BigDecimal amount) {
if (amount == null || amount.signum() <= 0)
throw new IllegalArgumentException("Amount must be positive: " + amount);
return amount;
}
public static void main(String[] args) {
System.out.println(requirePositiveAmount(new BigDecimal("19.99")));
for (BigDecimal bad : new BigDecimal[] {null, BigDecimal.ZERO, new BigDecimal("-1")}) {
try { requirePositiveAmount(bad); }
catch (IllegalArgumentException e) { System.out.println(e.getMessage()); }
}
System.out.println(0.1 + 0.2);
System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2")));
}
}
$ java AmountValidator.java
19.99
Amount must be positive: null
Amount must be positive: 0
Amount must be positive: -1
0.30000000000000004
0.3
signum() handles zero and negatives in one check. double cannot represent 0.1 exactly, so sums
drift; BigDecimal built from strings keeps decimal amounts exact.